Compare commits

..

11 Commits

Author SHA1 Message Date
88544c4c85 Update project memory and CLAUDE.md to reflect Phases 1-4 completion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:31:08 +02:00
5be3a777f9 Add selection outline post-process setup (material pending)
- PostProcessComponent on Pawn (unbound, applies to entire scene)
- Auto-loads M_PS_Editor_SelectionOutline material at BeginPlay
- Removed debug bounding box from Tick
- Custom depth stencil already toggled on selected actors
- Material needs to be created in UE editor (edge detection on CustomStencil)
- Instructions: use TexCoord + Constant2Vector offsets for 4-neighbor sampling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:28:24 +02:00
36b04bee61 Add Phase 4: JSON scene save/load system
Serialization:
- SceneData USTRUCT with Version, SceneName, Timestamp, Actors array
- ActorData: ClassPath, Location, Rotation, Scale per spawned actor
- SceneSerializer: SaveScene/LoadScene/ClearScene/GetSavedSceneNames
- Files stored in Saved/PS_Editor/Scenes/{name}.json
- Uses FJsonObjectConverter for clean USTRUCT-to-JSON conversion

SpawnManager:
- Tracks all spawned actors in SpawnedActors array
- SpawnActorDirect: spawn from class/transform (used by scene loading)
- DestroyAllSpawnedActors: clean scene clear

UI:
- Save field + button in toolbar area
- New Scene button to clear all spawned actors
- Saved scenes list with click-to-load
- Auto-refreshes scene list after save
- Clears selection on load/new

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:31:15 +02:00
9f1604f57c Add Phase 3: object spawn system with catalogue UI
Spawn System:
- SpawnableComponent: marker component for Blueprint actors (name, category, thumbnail)
- SpawnCatalog DataAsset: lists all spawnable Blueprints with Factory for easy creation
- SpawnManager: loads catalog, spawns actors in front of camera, ensures Movable mobility
- Reads metadata from Blueprint component templates (no temp spawn needed)

Catalogue UI:
- Left panel with scrollable list grouped by category
- Thumbnail cards (80x80) with name below, click to spawn
- "Capture All Thumbnails" button on DataAsset: auto-generates from UE thumbnails

Undo/Redo improvements:
- SpawnAction: undo hides spawned actor, redo shows it
- Delete confirmation dialog (Yes/No) before soft-delete
- Undo/redo auto-deselects hidden actors and refreshes gizmo
- Proper cleanup of hidden actors when actions pruned from stack

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:17:57 +02:00
ccb154c44f Add undo/redo system, properties panel, and toolbar UI
Undo/Redo:
- Custom runtime UndoManager (UE transaction system is editor-only)
- Ctrl+Z / Ctrl+Y hotkeys, action stack with 50 history limit
- Records: gizmo translate/rotate/scale, snap to ground, UI edits
- Soft-delete on Delete key (hide actor, undo restores it)
- Gizmo follows restored position on undo/redo

UI:
- Properties panel (right side): editable Location/Rotation/Scale fields
- Real-time updates during gizmo drag, manual input with Enter to apply
- Toolbar: Translate(E)/Rotate(R)/Scale(T) buttons + Snap Ground
- Mode indicator in title bar, updated help text with all shortcuts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:11:40 +02:00
20017ff3fd Add snap to ground, screen-space rotate/scale, and input refinements
- End key snaps selected actors to ground (downward trace + bounds offset)
- Rotate/Scale now use screen-space mouse delta (X+Y combined) for intuitive control
- Accumulator-based drag for smooth incremental rotation and scaling
- Fixed rotation drag plane (perpendicular to axis instead of parallel)
- Movement: arrows + A (up) / Q (down), letter keys freed for hotkeys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:49:04 +02:00
30c0078a1d Add rotate/scale gizmo modes, hotkeys, and delete
- E/R/T hotkeys to switch Translate/Rotate/Scale modes
- Rotate: drag gizmo axis to rotate selection around that axis
- Scale: drag gizmo axis to scale selection on that axis
- Delete key to destroy selected actors
- Movement keys: arrows only + A (up) / Q (down)
- Removed ZQSD movement to free letter keys for hotkeys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:47:20 +02:00
7884d1de59 Add gizmo drag interaction and hover highlighting
- Click+drag on gizmo arrows to translate selected actors along axis
- Drag plane computation based on axis direction and camera orientation
- Axis-constrained movement with initial transform preservation
- Hover highlighting: gizmo arrows turn yellow on mouse-over
- Sphere sweep for easier gizmo clicking (generous hit radius)
- UE editor-style gizmo colors (dark red/green/blue)
- Fixed gizmo visibility toggle (component-level instead of actor-level)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:31:40 +02:00
8b2609669f 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>
2026-04-10 15:06:11 +02:00
cf1e2f8ccf Add .gitignore for plugin build artifacts and remove tracked binaries
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:34:42 +02:00
b089a20512 Add PS_Editor plugin Phase 1: GameMode + Camera navigation
Runtime editor plugin with UE viewport-style camera controls:
- ZQSD/Arrows movement (always active), E/A for up/down
- LMB: pan (horizontal move + yaw), RMB: free fly look
- Alt+LMB / MMB: orbit around focal point, scroll: zoom
- HUD with controls help, cursor auto-hide during camera ops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:33:55 +02:00
53 changed files with 4449 additions and 1 deletions

4
.claude/memory/MEMORY.md Normal file
View File

@@ -0,0 +1,4 @@
- [PS_Editor plugin project](project_ps_editor.md) — Runtime editor plugin for UE 5.5, Phases 1-4 done, outline material pending
- [Underscore naming](feedback_naming.md) — Always use PS_Editor_ prefix with underscores in file/class names
- [UE5 build process](reference_build.md) — CLI build command for the project (Build.bat path, target name, flags)
- [Build workflow](feedback_build_workflow.md) — Prefer Live Coding (Ctrl+Alt+F11) when UE is open, CLI build only when UE is closed

View File

@@ -0,0 +1,11 @@
---
name: Build workflow preference
description: User prefers Live Coding (Ctrl+Alt+F11) over CLI build when UE editor is open
type: feedback
---
When UE editor is open, ask the user to do a Live Coding compile (Ctrl+Alt+F11) instead of trying CLI build. Only ask to close UE for a full rebuild when Live Coding is not sufficient (e.g., header changes that require UHT, new .generated.h files, Build.cs changes).
**Why:** User finds Live Coding simpler and faster for iteration. CLI build fails when Live Coding is active.
**How to apply:** After code changes, tell the user to press Ctrl+Alt+F11. If changes involve new UCLASS/USTRUCT/UENUM declarations, new files, or Build.cs modifications, warn that a full restart of UE + CLI build is needed instead.

View File

@@ -0,0 +1,11 @@
---
name: Underscore naming convention
description: User requires PS_Editor_ prefix with underscores in all file and class names
type: feedback
---
Keep the underscore in file and class names: PS_Editor_ prefix.
**Why:** User explicitly corrected when I used PSEditor (camelCase) format. They want PS_Editor_GameMode, PS_Editor_Pawn, etc.
**How to apply:** Always use PS_Editor_ prefix for any new file or class in the plugin. Never use PSEditor or PS_EditorXyz without the separating underscore.

View File

@@ -0,0 +1,47 @@
---
name: PS_Editor plugin project
description: Runtime editor plugin for UE 5.5 - object placement, scene editing, JSON save/load in packaged builds
type: project
---
PS_Editor is a runtime editor plugin for Unreal Engine 5.5 for the PROSERVE project.
Key decisions:
- UI: UMG driven by C++ (Slate)
- Gizmos: Custom actor with BasicShapes + unlit material (ITF is editor-only)
- Serialization: JSON via Json/JsonUtilities (FJsonObjectConverter)
- Naming: PS_Editor_ prefix on all files/classes
- Plugin path: Unreal/Plugins/PS_Editor/
- AZERTY keyboard: arrows + A/Q for movement, E/R/T for modes
## Completed Phases
### Phase 1 - GameMode + Camera
- Camera: Fly (RMB), Pan (LMB drag), Orbit (Alt+LMB / MMB)
- Movement: arrows + A (up) / Q (down)
### Phase 2 - Selection + Transform + Undo
- Click-to-select (Movable actors only), Ctrl+click multi-select
- Custom gizmo (R/G/B arrows + cones, SDPG_Foreground, constant screen size)
- Translate/Rotate/Scale via gizmo drag, hotkeys E/R/T
- Snap to ground (End), soft-delete (Delete + confirmation dialog)
- Undo/Redo (Ctrl+Z/Y, 50 action stack)
- Properties panel + toolbar UI
- Material: M_PS_Editor_Gizmo (Unlit, "Color" param, Translucent, Disable Depth Test)
### Phase 3 - Object Spawning
- SpawnableComponent: marker on BPs (name, category, thumbnail)
- SpawnCatalog DataAsset + Factory for easy creation
- SpawnManager: spawns in front of camera, tracks all spawned actors
- Catalogue UI: thumbnail cards, click to spawn, grouped by category
- CaptureAllThumbnails on DataAsset: auto-generates from UE thumbnails
- Spawn undo/redo, auto-deselect hidden actors
### Phase 4 - JSON Serialization
- SceneData USTRUCT (Version, SceneName, Timestamp, Actors[ClassPath, Location, Rotation, Scale])
- SceneSerializer: Save/Load/Clear/GetSavedSceneNames
- Files in Saved/PS_Editor/Scenes/{name}.json
- UI: Save field + button, New Scene, saved scenes list with click-to-load
## Next: Phase 5 - Advanced Editing
- Splines, lighting, AI characters, property editing

View File

@@ -0,0 +1,18 @@
---
name: UE5 build process
description: How to build the PS_ProserveEditor UE 5.5 project from command line on Windows
type: reference
---
UE 5.5 is installed at `C:\Program Files\Epic Games\UE_5.5` (found via registry key `HKLM\SOFTWARE\EpicGames\Unreal Engine\5.5`).
Build command (Editor target, Development):
```
"C:/Program Files/Epic Games/UE_5.5/Engine/Build/BatchFiles/Build.bat" PS_ProserveEditorEditor Win64 Development -Project="C:/ASTERION/GIT/PS_ProserveEditor/Unreal/PS_ProserveEditor.uproject" -WaitMutex -FromMsBuild
```
**How to apply:** Use this command to compile after code changes. Timeout should be set to 600000ms (10 min) for safety. The target name is `PS_ProserveEditorEditor` (Editor suffix) for editor builds.
**When to use CLI vs Live Coding:**
- Live Coding (Ctrl+Alt+F11): .cpp only changes (no new UCLASS, USTRUCT, UENUM, UPROPERTY in headers)
- Full CLI build (UE must be closed): new files, header changes with new reflected types, Build.cs changes

13
.gitignore vendored
View File

@@ -1,4 +1,17 @@
# Visual Studio
Unreal/.vs/
# UE build artifacts (project)
Unreal/Binaries/
Unreal/Intermediate/
Unreal/Saved/
Unreal/DerivedDataCache/
# UE build artifacts (plugins)
Unreal/Plugins/*/Binaries/
Unreal/Plugins/*/Intermediate/
# OS
Thumbs.db
Desktop.ini
.DS_Store

40
CLAUDE.md Normal file
View File

@@ -0,0 +1,40 @@
# PS_ProserveEditor - Project Guidelines
## Project Overview
Unreal Engine 5.5 project with runtime editor plugin (PS_Editor). The plugin enables runtime scene editing in packaged builds: object placement, property editing, scenario definition, splines, lighting, and JSON scene save/load.
## Architecture
- **Plugin**: PS_Editor (runtime plugin, must work in packaged builds)
- **UI**: UMG driven by C++ (Slate)
- **Gizmos**: Custom actor with BasicShapes + unlit material (InteractiveToolsFramework is editor-only)
- **Serialization**: JSON via Json/JsonUtilities modules (FJsonObjectConverter)
- **Input**: Enhanced Input system (AZERTY: arrows + A/Q movement, E/R/T modes)
## Naming Conventions
- All plugin files and classes use the `PS_Editor_` prefix (e.g., `PS_Editor_GameMode`, `PS_Editor_Pawn`)
- Code and comments in English
- Standard UE5 prefixes: `A` for Actors, `U` for UObjects, `F` for structs, `E` for enums, `I` for interfaces
## Code Quality
- Prefer correct, complete implementations over minimal ones.
- Use appropriate data structures and algorithms — don't brute-force what has a known better solution.
- When fixing a bug, fix the root cause, not the symptom.
- If something requires error handling or validation to work reliably, include it without asking.
## Build
- Engine: Unreal Engine 5.5
- Plugin location: `Unreal/Plugins/PS_Editor/`
- Game module: `PS_ProserveEditor` (in `Unreal/Source/PS_ProserveEditor/`)
- CLI build command (Editor, Development):
```
"<UE_INSTALL>/Engine/Build/BatchFiles/Build.bat" PS_ProserveEditorEditor Win64 Development -Project="<REPO>/Unreal/PS_ProserveEditor.uproject" -WaitMutex -FromMsBuild
```
UE install path can be found via registry: `HKLM\SOFTWARE\EpicGames\Unreal Engine\5.5` > `InstalledDirectory`
## Project Memory
Shared project memory is stored in `.claude/memory/` (versioned in the repository). When starting a new session on any machine, read these files to restore context:
- `MEMORY.md` — Index of all memory files with one-line summaries
- `project_ps_editor.md` — Project scope, technical decisions, current phase
- `feedback_naming.md` — Naming conventions and user preferences
- `reference_build.md` — Full build command and UE install path details

View File

@@ -1,7 +1,9 @@
[/Script/EngineSettings.GameMapsSettings]
GameDefaultMap=/Engine/Maps/Templates/OpenWorld
GameDefaultMap=/Game/test.test
EditorStartupMap=/Game/test.test
GlobalDefaultGameMode=/Script/PS_Editor.PS_Editor_GameMode
[/Script/Engine.RendererSettings]
r.AllowStaticLighting=False
@@ -14,6 +16,8 @@ r.ReflectionMethod=1
r.SkinCache.CompileShaders=True
r.CustomDepth=3
r.RayTracing=True
r.Shadow.Virtual.Enable=1

BIN
Unreal/Content/test.umap Normal file

Binary file not shown.

View File

@@ -17,6 +17,10 @@
"TargetAllowList": [
"Editor"
]
},
{
"Name": "PS_Editor",
"Enabled": true
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,25 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "0.1.0",
"FriendlyName": "PS Editor",
"Description": "Runtime scene editor plugin for Proserve. Enables object placement, property editing, scenario definition, and JSON scene save/load in packaged builds.",
"Category": "Proserve",
"CreatedBy": "Asterion",
"CanContainContent": true,
"IsBetaVersion": true,
"Installed": false,
"Modules": [
{
"Name": "PS_Editor",
"Type": "Runtime",
"LoadingPhase": "Default"
}
],
"Plugins": [
{
"Name": "EnhancedInput",
"Enabled": true
}
]
}

View File

@@ -0,0 +1,11 @@
#include "PS_Editor_GameMode.h"
#include "PS_Editor_Pawn.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_HUD.h"
APS_Editor_GameMode::APS_Editor_GameMode()
{
DefaultPawnClass = APS_Editor_Pawn::StaticClass();
PlayerControllerClass = APS_Editor_PlayerController::StaticClass();
HUDClass = APS_Editor_HUD::StaticClass();
}

View File

@@ -0,0 +1,22 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "PS_Editor_GameMode.generated.h"
/**
* Game mode for the PS_Editor runtime editing mode.
* Sets up the editor pawn, controller, and HUD.
*/
UCLASS()
class PS_EDITOR_API APS_Editor_GameMode : public AGameModeBase
{
GENERATED_BODY()
public:
APS_Editor_GameMode();
/** Whether the editor is currently in edit mode (vs play/preview mode). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
bool bIsEditMode = true;
};

View File

@@ -0,0 +1,832 @@
#include "PS_Editor_Pawn.h"
#include "Camera/CameraComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Components/PostProcessComponent.h"
#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "InputAction.h"
#include "InputMappingContext.h"
#include "InputModifiers.h"
#include "InputTriggers.h"
#include "GameFramework/PlayerController.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_UndoManager.h"
#include "DrawDebugHelpers.h"
#include "Misc/MessageDialog.h"
APS_Editor_Pawn::APS_Editor_Pawn()
{
PrimaryActorTick.bCanEverTick = true;
RootScene = CreateDefaultSubobject<USceneComponent>(TEXT("RootScene"));
SetRootComponent(RootScene);
SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
SpringArm->SetupAttachment(RootScene);
SpringArm->TargetArmLength = 0.0f;
SpringArm->bDoCollisionTest = false;
SpringArm->bEnableCameraLag = false;
SpringArm->bUsePawnControlRotation = false;
Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
Camera->SetupAttachment(SpringArm);
OutlinePostProcess = CreateDefaultSubobject<UPostProcessComponent>(TEXT("OutlinePostProcess"));
OutlinePostProcess->SetupAttachment(RootScene);
OutlinePostProcess->bUnbound = true; // Apply to entire scene
}
void APS_Editor_Pawn::BeginPlay()
{
Super::BeginPlay();
// Load outline post-process material
UMaterialInterface* OutlineMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_SelectionOutline.M_PS_Editor_SelectionOutline"));
if (OutlineMat && OutlinePostProcess)
{
OutlinePostProcess->Settings.WeightedBlendables.Array.Add(FWeightedBlendable(1.0f, OutlineMat));
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Selection outline material loaded"));
}
else
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Outline material /PS_Editor/M_PS_Editor_SelectionOutline not found. "
"Create a Post Process material in Plugins/PS_Editor/Content/"));
}
}
void APS_Editor_Pawn::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (APlayerController* PC = Cast<APlayerController>(GetController()))
{
bAltHeld = PC->IsInputKeyDown(EKeys::LeftAlt) || PC->IsInputKeyDown(EKeys::RightAlt);
bCtrlHeld = PC->IsInputKeyDown(EKeys::LeftControl) || PC->IsInputKeyDown(EKeys::RightControl);
// Gizmo hover highlighting in Idle mode
if (CameraMode == EPS_Editor_CameraMode::Idle)
{
UpdateGizmoHover();
}
}
}
void APS_Editor_Pawn::CreateInputActions()
{
IA_Move = NewObject<UInputAction>(this, TEXT("IA_Move"));
IA_Move->ValueType = EInputActionValueType::Axis3D;
IA_Move->bConsumeInput = false;
IA_Look = NewObject<UInputAction>(this, TEXT("IA_Look"));
IA_Look->ValueType = EInputActionValueType::Axis2D;
IA_Scroll = NewObject<UInputAction>(this, TEXT("IA_Scroll"));
IA_Scroll->ValueType = EInputActionValueType::Axis1D;
IA_LeftClick = NewObject<UInputAction>(this, TEXT("IA_LeftClick"));
IA_LeftClick->ValueType = EInputActionValueType::Boolean;
IA_RightClick = NewObject<UInputAction>(this, TEXT("IA_RightClick"));
IA_RightClick->ValueType = EInputActionValueType::Boolean;
IA_MiddleClick = NewObject<UInputAction>(this, TEXT("IA_MiddleClick"));
IA_MiddleClick->ValueType = EInputActionValueType::Boolean;
EditorMappingContext = NewObject<UInputMappingContext>(this, TEXT("EditorMappingContext"));
// Movement helpers
auto MapForward = [&](FKey Key)
{
FEnhancedActionKeyMapping& Mapping = EditorMappingContext->MapKey(IA_Move, Key);
UInputModifierSwizzleAxis* Swizzle = NewObject<UInputModifierSwizzleAxis>(this);
Swizzle->Order = EInputAxisSwizzle::YXZ;
Mapping.Modifiers.Add(Swizzle);
};
auto MapBackward = [&](FKey Key)
{
FEnhancedActionKeyMapping& Mapping = EditorMappingContext->MapKey(IA_Move, Key);
UInputModifierSwizzleAxis* Swizzle = NewObject<UInputModifierSwizzleAxis>(this);
Swizzle->Order = EInputAxisSwizzle::YXZ;
Mapping.Modifiers.Add(Swizzle);
Mapping.Modifiers.Add(NewObject<UInputModifierNegate>(this));
};
auto MapRight = [&](FKey Key)
{
EditorMappingContext->MapKey(IA_Move, Key);
};
auto MapLeft = [&](FKey Key)
{
FEnhancedActionKeyMapping& Mapping = EditorMappingContext->MapKey(IA_Move, Key);
Mapping.Modifiers.Add(NewObject<UInputModifierNegate>(this));
};
// Arrow keys only (letter keys reserved for hotkeys)
MapForward(EKeys::Up);
MapBackward(EKeys::Down);
MapRight(EKeys::Right);
MapLeft(EKeys::Left);
// A - Up (+Z)
{
FEnhancedActionKeyMapping& Mapping = EditorMappingContext->MapKey(IA_Move, EKeys::A);
UInputModifierSwizzleAxis* Swizzle = NewObject<UInputModifierSwizzleAxis>(this);
Swizzle->Order = EInputAxisSwizzle::ZYX;
Mapping.Modifiers.Add(Swizzle);
}
// Q - Down (-Z)
{
FEnhancedActionKeyMapping& Mapping = EditorMappingContext->MapKey(IA_Move, EKeys::Q);
UInputModifierSwizzleAxis* Swizzle = NewObject<UInputModifierSwizzleAxis>(this);
Swizzle->Order = EInputAxisSwizzle::ZYX;
Mapping.Modifiers.Add(Swizzle);
Mapping.Modifiers.Add(NewObject<UInputModifierNegate>(this));
}
// Mouse
EditorMappingContext->MapKey(IA_Look, EKeys::Mouse2D);
EditorMappingContext->MapKey(IA_Scroll, EKeys::MouseWheelAxis);
EditorMappingContext->MapKey(IA_LeftClick, EKeys::LeftMouseButton);
EditorMappingContext->MapKey(IA_RightClick, EKeys::RightMouseButton);
EditorMappingContext->MapKey(IA_MiddleClick, EKeys::MiddleMouseButton);
// Transform mode hotkeys (E/R/T)
IA_TranslateMode = NewObject<UInputAction>(this, TEXT("IA_TranslateMode"));
IA_TranslateMode->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_TranslateMode, EKeys::E);
IA_RotateMode = NewObject<UInputAction>(this, TEXT("IA_RotateMode"));
IA_RotateMode->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_RotateMode, EKeys::R);
IA_ScaleMode = NewObject<UInputAction>(this, TEXT("IA_ScaleMode"));
IA_ScaleMode->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_ScaleMode, EKeys::T);
IA_Delete = NewObject<UInputAction>(this, TEXT("IA_Delete"));
IA_Delete->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Delete, EKeys::Delete);
IA_SnapToGround = NewObject<UInputAction>(this, TEXT("IA_SnapToGround"));
IA_SnapToGround->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_SnapToGround, EKeys::End);
// Undo/Redo (Ctrl+Z / Ctrl+Y handled via key state check in handler)
IA_Undo = NewObject<UInputAction>(this, TEXT("IA_Undo"));
IA_Undo->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Undo, EKeys::Z);
IA_Redo = NewObject<UInputAction>(this, TEXT("IA_Redo"));
IA_Redo->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Redo, EKeys::Y);
}
void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
CreateInputActions();
if (APlayerController* PC = Cast<APlayerController>(GetController()))
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PC->GetLocalPlayer()))
{
Subsystem->ClearAllMappings();
Subsystem->AddMappingContext(EditorMappingContext, 0);
}
}
UEnhancedInputComponent* EIC = CastChecked<UEnhancedInputComponent>(PlayerInputComponent);
EIC->BindAction(IA_Move, ETriggerEvent::Triggered, this, &APS_Editor_Pawn::HandleMove);
EIC->BindAction(IA_Look, ETriggerEvent::Triggered, this, &APS_Editor_Pawn::HandleLook);
EIC->BindAction(IA_Scroll, ETriggerEvent::Triggered, this, &APS_Editor_Pawn::HandleScroll);
EIC->BindAction(IA_LeftClick, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleLeftClickStarted);
EIC->BindAction(IA_LeftClick, ETriggerEvent::Completed, this, &APS_Editor_Pawn::HandleLeftClickCompleted);
EIC->BindAction(IA_RightClick, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleRightClickStarted);
EIC->BindAction(IA_RightClick, ETriggerEvent::Completed, this, &APS_Editor_Pawn::HandleRightClickCompleted);
EIC->BindAction(IA_MiddleClick, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleMiddleClickStarted);
EIC->BindAction(IA_MiddleClick, ETriggerEvent::Completed, this, &APS_Editor_Pawn::HandleMiddleClickCompleted);
// Transform mode hotkeys (only trigger on press, not hold)
EIC->BindAction(IA_TranslateMode, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleTranslateMode);
EIC->BindAction(IA_RotateMode, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleRotateMode);
EIC->BindAction(IA_ScaleMode, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleScaleMode);
EIC->BindAction(IA_Delete, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleDelete);
EIC->BindAction(IA_SnapToGround, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleSnapToGround);
EIC->BindAction(IA_Undo, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleUndo);
EIC->BindAction(IA_Redo, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleRedo);
}
// ---- Input Handlers ----
void APS_Editor_Pawn::HandleMove(const FInputActionValue& Value)
{
const FVector MoveInput = Value.Get<FVector>();
const float DeltaTime = GetWorld()->GetDeltaSeconds();
const FRotator Rotation = GetActorRotation();
const FVector Forward = FRotationMatrix(Rotation).GetUnitAxis(EAxis::X);
const FVector Right = FRotationMatrix(Rotation).GetUnitAxis(EAxis::Y);
const FVector Up = FVector::UpVector;
const FVector Movement = (Forward * MoveInput.Y) + (Right * MoveInput.X) + (Up * MoveInput.Z);
AddActorWorldOffset(Movement * FlySpeed * DeltaTime, true);
}
void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
{
if (CameraMode == EPS_Editor_CameraMode::Idle)
{
return;
}
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)
{
const float YawDelta = LookInput.X * PanYawSensitivity;
FRotator CurrentRotation = GetActorRotation();
CurrentRotation.Yaw += YawDelta;
SetActorRotation(CurrentRotation);
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)
{
const float YawDelta = LookInput.X * LookSensitivity;
const float PitchDelta = LookInput.Y * LookSensitivity;
FRotator CurrentRotation = GetActorRotation();
CurrentRotation.Yaw += YawDelta;
CurrentRotation.Pitch = FMath::Clamp(CurrentRotation.Pitch + PitchDelta, -89.0f, 89.0f);
CurrentRotation.Roll = 0.0f;
SetActorRotation(CurrentRotation);
}
else if (CameraMode == EPS_Editor_CameraMode::Orbit)
{
OrbitYaw += LookInput.X * OrbitSensitivity;
OrbitPitch = FMath::Clamp(OrbitPitch + (LookInput.Y * OrbitSensitivity), -89.0f, 89.0f);
const FRotator OrbitRotation(OrbitPitch, OrbitYaw, 0.0f);
const FVector Offset = OrbitRotation.Vector() * -OrbitDistance;
SetActorLocation(OrbitFocalPoint + Offset);
SetActorRotation((OrbitFocalPoint - GetActorLocation()).Rotation());
}
else if (CameraMode == EPS_Editor_CameraMode::GizmoDrag)
{
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
if (!EditorPC) return;
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
if (!SM) return;
APS_Editor_Gizmo* Gizmo = SM->GetGizmo();
if (!Gizmo) return;
const FVector AxisDir = Gizmo->GetAxisDirection(ActiveGizmoAxis);
const EPS_Editor_TransformMode Mode = SM->GetTransformMode();
const TArray<TWeakObjectPtr<AActor>>& Selected = SM->GetSelectedActors();
if (Mode == EPS_Editor_TransformMode::Translate)
{
// Ray-plane intersection for precise axis-constrained translation
FVector RayOrigin, RayDir;
if (!GetMouseWorldRay(RayOrigin, RayDir)) return;
FVector CurrentIntersection;
if (!RayPlaneIntersection(RayOrigin, RayDir, GizmoDragPlaneOrigin, GizmoDragPlaneNormal, CurrentIntersection)) return;
const FVector TotalDelta = CurrentIntersection - GizmoDragInitialIntersection;
const FVector ConstrainedDelta = FVector::DotProduct(TotalDelta, AxisDir) * AxisDir;
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
{
if (AActor* Actor = Selected[i].Get())
{
Actor->SetActorLocation(GizmoDragInitialTransforms[i].GetLocation() + ConstrainedDelta);
}
}
}
else if (Mode == EPS_Editor_TransformMode::Rotate)
{
// Use combined mouse X+Y screen movement to drive rotation angle
const float RotationSpeed = 1.0f;
const float AngleDelta = (LookInput.X + LookInput.Y) * RotationSpeed;
// Accumulate total angle since drag start
GizmoDragAccumulator += AngleDelta;
const FQuat RotationQuat(AxisDir, FMath::DegreesToRadians(GizmoDragAccumulator));
const FVector GizmoLoc = GizmoDragPlaneOrigin;
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
{
if (AActor* Actor = Selected[i].Get())
{
const FVector Offset = GizmoDragInitialTransforms[i].GetLocation() - GizmoLoc;
Actor->SetActorLocation(GizmoLoc + RotationQuat.RotateVector(Offset));
const FQuat OrigRot = GizmoDragInitialTransforms[i].GetRotation();
Actor->SetActorRotation((RotationQuat * OrigRot).Rotator());
}
}
}
else if (Mode == EPS_Editor_TransformMode::Scale)
{
// Use combined mouse X+Y screen movement to drive scale
const float ScaleSpeed = 0.01f;
GizmoDragAccumulator += (LookInput.X + LookInput.Y) * ScaleSpeed;
const float ScaleFactor = FMath::Clamp(1.0f + GizmoDragAccumulator, 0.01f, 100.0f);
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
{
if (AActor* Actor = Selected[i].Get())
{
FVector NewScale = GizmoDragInitialTransforms[i].GetScale3D();
if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::X) NewScale.X *= ScaleFactor;
else if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::Y) NewScale.Y *= ScaleFactor;
else if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::Z) NewScale.Z *= ScaleFactor;
Actor->SetActorScale3D(NewScale);
}
}
}
Gizmo->SetActorLocation(SM->GetSelectionPivot());
}
}
void APS_Editor_Pawn::HandleScroll(const FInputActionValue& Value)
{
const float ScrollValue = Value.Get<float>();
if (CameraMode == EPS_Editor_CameraMode::Fly)
{
FlySpeed = FMath::Clamp(FlySpeed + ScrollValue * FlySpeedScrollStep, FlySpeedMin, FlySpeedMax);
}
else if (CameraMode == EPS_Editor_CameraMode::Orbit)
{
OrbitDistance = FMath::Clamp(OrbitDistance - ScrollValue * OrbitZoomStep, OrbitDistanceMin, OrbitDistanceMax);
const FRotator OrbitRotation(OrbitPitch, OrbitYaw, 0.0f);
const FVector Offset = OrbitRotation.Vector() * -OrbitDistance;
SetActorLocation(OrbitFocalPoint + Offset);
}
else
{
const FVector Forward = GetActorForwardVector();
AddActorWorldOffset(Forward * ScrollValue * OrbitZoomStep * 5.0f, true);
}
}
void APS_Editor_Pawn::HandleLeftClickStarted(const FInputActionValue& Value)
{
if (bAltHeld)
{
ComputeOrbitFocalPoint();
SetCameraMode(EPS_Editor_CameraMode::Orbit);
return;
}
// Check if clicking on a gizmo handle
FVector RayOrigin, RayDir;
if (GetMouseWorldRay(RayOrigin, RayDir))
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
if (APS_Editor_Gizmo* Gizmo = SM->GetGizmo())
{
EPS_Editor_GizmoAxis Axis = Gizmo->GetAxisUnderCursor(RayOrigin, RayDir);
if (Axis != EPS_Editor_GizmoAxis::None)
{
BeginGizmoDrag(Axis);
return;
}
}
}
}
}
// No gizmo hit: enter PendingClick
PendingClickScreenPos = GetMouseScreenPosition();
SetCameraMode(EPS_Editor_CameraMode::PendingClick);
}
void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value)
{
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::GizmoDrag)
{
// Record undo action for the completed gizmo drag
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
TSharedPtr<FPS_Editor_TransformAction> Action = MakeShared<FPS_Editor_TransformAction>();
const TArray<TWeakObjectPtr<AActor>>& Selected = SM->GetSelectedActors();
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
{
if (AActor* Actor = Selected[i].Get())
{
Action->Actors.Add(Actor);
Action->OldTransforms.Add(GizmoDragInitialTransforms[i]);
Action->NewTransforms.Add(Actor->GetActorTransform());
}
}
FString ModeStr;
switch (SM->GetTransformMode())
{
case EPS_Editor_TransformMode::Translate: ModeStr = TEXT("Translate"); break;
case EPS_Editor_TransformMode::Rotate: ModeStr = TEXT("Rotate"); break;
case EPS_Editor_TransformMode::Scale: ModeStr = TEXT("Scale"); break;
}
Action->Description = FString::Printf(TEXT("%s %d actor(s)"), *ModeStr, Action->Actors.Num());
if (Action->Actors.Num() > 0)
{
UM->RecordAction(Action);
}
}
}
}
ActiveGizmoAxis = EPS_Editor_GizmoAxis::None;
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
else if (CameraMode == EPS_Editor_CameraMode::Pan || CameraMode == EPS_Editor_CameraMode::Orbit)
{
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
}
void APS_Editor_Pawn::HandleRightClickStarted(const FInputActionValue& Value)
{
SetCameraMode(EPS_Editor_CameraMode::Fly);
}
void APS_Editor_Pawn::HandleRightClickCompleted(const FInputActionValue& Value)
{
if (CameraMode == EPS_Editor_CameraMode::Fly)
{
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
}
void APS_Editor_Pawn::HandleMiddleClickStarted(const FInputActionValue& Value)
{
ComputeOrbitFocalPoint();
SetCameraMode(EPS_Editor_CameraMode::Orbit);
}
void APS_Editor_Pawn::HandleMiddleClickCompleted(const FInputActionValue& Value)
{
if (CameraMode == EPS_Editor_CameraMode::Orbit)
{
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
}
void APS_Editor_Pawn::HandleTranslateMode(const FInputActionValue& Value)
{
if (CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
SM->SetTransformMode(EPS_Editor_TransformMode::Translate);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Translate mode"));
}
}
}
void APS_Editor_Pawn::HandleRotateMode(const FInputActionValue& Value)
{
if (CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
SM->SetTransformMode(EPS_Editor_TransformMode::Rotate);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Rotate mode"));
}
}
}
void APS_Editor_Pawn::HandleScaleMode(const FInputActionValue& Value)
{
if (CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
SM->SetTransformMode(EPS_Editor_TransformMode::Scale);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scale mode"));
}
}
}
void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
{
if (CameraMode != EPS_Editor_CameraMode::Idle) return;
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
if (!EditorPC) return;
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
if (!SM || !SM->IsAnythingSelected()) return;
// Confirmation dialog
const int32 Count = SM->GetSelectedActors().Num();
const FText Message = FText::Format(
NSLOCTEXT("PS_Editor", "DeleteConfirm", "Delete {0} selected object(s)?"),
FText::AsNumber(Count));
EAppReturnType::Type Result = FMessageDialog::Open(EAppMsgType::YesNo, Message);
if (Result != EAppReturnType::Yes)
{
return;
}
// Soft-delete: hide actors instead of destroying (enables undo)
TSharedPtr<FPS_Editor_DeleteAction> Action = MakeShared<FPS_Editor_DeleteAction>();
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
{
if (AActor* Actor = Weak.Get())
{
Action->Actors.Add(Actor);
}
}
SM->ClearSelection();
Action->Execute();
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->RecordAction(Action);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Soft-deleted %d actor(s)"), Count);
}
void APS_Editor_Pawn::HandleUndo(const FInputActionValue& Value)
{
if (!bCtrlHeld || CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->Undo();
}
}
}
void APS_Editor_Pawn::HandleRedo(const FInputActionValue& Value)
{
if (!bCtrlHeld || CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->Redo();
}
}
}
void APS_Editor_Pawn::HandleSnapToGround(const FInputActionValue& Value)
{
if (CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
SM->SnapSelectionToGround();
}
}
}
// ---- Helpers ----
void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode)
{
CameraMode = NewMode;
if (NewMode == EPS_Editor_CameraMode::Orbit)
{
const FRotator CurrentRotation = GetActorRotation();
OrbitPitch = CurrentRotation.Pitch;
OrbitYaw = CurrentRotation.Yaw;
}
UpdateCursorVisibility();
}
void APS_Editor_Pawn::UpdateCursorVisibility()
{
APlayerController* PC = Cast<APlayerController>(GetController());
if (!PC)
{
return;
}
const bool bShowCursor = (CameraMode == EPS_Editor_CameraMode::Idle
|| CameraMode == EPS_Editor_CameraMode::PendingClick);
PC->bShowMouseCursor = bShowCursor;
if (bShowCursor)
{
PC->SetInputMode(FInputModeGameAndUI().SetHideCursorDuringCapture(false));
}
else
{
PC->SetInputMode(FInputModeGameOnly());
}
}
void APS_Editor_Pawn::ComputeOrbitFocalPoint()
{
const FVector Start = GetActorLocation();
const FVector Forward = GetActorForwardVector();
const FVector End = Start + Forward * OrbitDistance;
FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(this);
if (GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Visibility, Params))
{
OrbitFocalPoint = Hit.ImpactPoint;
OrbitDistance = FVector::Dist(Start, OrbitFocalPoint);
}
else
{
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);
}
bool APS_Editor_Pawn::GetMouseWorldRay(FVector& OutOrigin, FVector& OutDirection) const
{
APlayerController* PC = Cast<APlayerController>(GetController());
if (!PC)
{
return false;
}
const FVector2D ScreenPos = GetMouseScreenPosition();
return PC->DeprojectScreenPositionToWorld(ScreenPos.X, ScreenPos.Y, OutOrigin, OutDirection);
}
bool APS_Editor_Pawn::RayPlaneIntersection(const FVector& RayOrigin, const FVector& RayDir,
const FVector& PlaneOrigin, const FVector& PlaneNormal, FVector& OutIntersection) const
{
const float Denom = FVector::DotProduct(RayDir, PlaneNormal);
if (FMath::Abs(Denom) < KINDA_SMALL_NUMBER)
{
return false; // Ray is parallel to plane
}
const float T = FVector::DotProduct(PlaneOrigin - RayOrigin, PlaneNormal) / Denom;
if (T < 0.0f)
{
return false; // Intersection is behind the ray
}
OutIntersection = RayOrigin + RayDir * T;
return true;
}
void APS_Editor_Pawn::UpdateGizmoHover()
{
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
if (!EditorPC) return;
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
if (!SM) return;
APS_Editor_Gizmo* Gizmo = SM->GetGizmo();
if (!Gizmo) return;
FVector RayOrigin, RayDir;
if (GetMouseWorldRay(RayOrigin, RayDir))
{
EPS_Editor_GizmoAxis Axis = Gizmo->GetAxisUnderCursor(RayOrigin, RayDir);
Gizmo->SetHighlightedAxis(Axis);
}
else
{
Gizmo->SetHighlightedAxis(EPS_Editor_GizmoAxis::None);
}
}
void APS_Editor_Pawn::BeginGizmoDrag(EPS_Editor_GizmoAxis Axis)
{
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
if (!EditorPC) return;
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
if (!SM) return;
APS_Editor_Gizmo* Gizmo = SM->GetGizmo();
if (!Gizmo) return;
ActiveGizmoAxis = Axis;
const FVector AxisDir = Gizmo->GetAxisDirection(Axis);
const EPS_Editor_TransformMode Mode = SM->GetTransformMode();
FVector PlaneNormal;
if (Mode == EPS_Editor_TransformMode::Rotate)
{
// For rotation: plane perpendicular to the rotation axis
PlaneNormal = AxisDir;
}
else
{
// For translate/scale: plane containing the axis, facing the camera
const FVector CameraForward = GetActorForwardVector();
PlaneNormal = FVector::CrossProduct(AxisDir, CameraForward).GetSafeNormal();
if (PlaneNormal.IsNearlyZero())
{
PlaneNormal = FVector::CrossProduct(AxisDir, FVector::UpVector).GetSafeNormal();
}
PlaneNormal = FVector::CrossProduct(AxisDir, PlaneNormal).GetSafeNormal();
}
GizmoDragPlaneNormal = PlaneNormal;
GizmoDragPlaneOrigin = Gizmo->GetActorLocation();
GizmoDragAccumulator = 0.0f;
// Get initial mouse-plane intersection
FVector RayOrigin, RayDir;
if (GetMouseWorldRay(RayOrigin, RayDir))
{
RayPlaneIntersection(RayOrigin, RayDir, GizmoDragPlaneOrigin, GizmoDragPlaneNormal, GizmoDragInitialIntersection);
}
// Store initial transforms of all selected actors
GizmoDragInitialTransforms.Empty();
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
{
if (AActor* Actor = Weak.Get())
{
GizmoDragInitialTransforms.Add(Actor->GetActorTransform());
}
else
{
GizmoDragInitialTransforms.Add(FTransform::Identity);
}
}
SetCameraMode(EPS_Editor_CameraMode::GizmoDrag);
}

View File

@@ -0,0 +1,220 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "InputActionValue.h"
#include "PS_Editor_SelectionTypes.h"
#include "PS_Editor_Pawn.generated.h"
class UCameraComponent;
class USpringArmComponent;
class UPostProcessComponent;
class UInputAction;
class UInputMappingContext;
DECLARE_DELEGATE_TwoParams(FPS_Editor_OnClick, FVector2D /*ScreenPos*/, bool /*bAdditive*/);
/**
* Camera modes for the editor pawn.
*/
UENUM(BlueprintType)
enum class EPS_Editor_CameraMode : uint8
{
/** Default mode - cursor visible, ZQSD/arrows still move. */
Idle,
/** 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 or middle-click: orbit around focal point. */
Orbit,
/** Dragging a gizmo handle. */
GizmoDrag
};
/**
* Editor camera pawn mimicking UE viewport navigation.
*
* Controls:
* - 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)
* - ZQSD / Arrows: Always active movement
* - E/A: Up/Down
* - Mouse wheel: Zoom / adjust fly speed
*/
UCLASS()
class PS_EDITOR_API APS_Editor_Pawn : public APawn
{
GENERATED_BODY()
public:
APS_Editor_Pawn();
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;
// ---- Components ----
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<USceneComponent> RootScene;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<USpringArmComponent> SpringArm;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<UCameraComponent> Camera;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<UPostProcessComponent> OutlinePostProcess;
// ---- Camera Settings ----
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
float FlySpeed = 1000.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
float FlySpeedMin = 100.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
float FlySpeedMax = 10000.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
float LookSensitivity = 2.5f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
float FlySpeedScrollStep = 200.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Pan")
float PanSpeed = 5.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Pan")
float PanYawSensitivity = 1.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Orbit")
float OrbitDistance = 500.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Orbit")
float OrbitZoomStep = 50.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Orbit")
float OrbitDistanceMin = 50.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Orbit")
float OrbitDistanceMax = 5000.0f;
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;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera|Orbit")
FVector OrbitFocalPoint = FVector::ZeroVector;
private:
// ---- Input Actions (created at runtime) ----
UPROPERTY()
TObjectPtr<UInputMappingContext> EditorMappingContext;
UPROPERTY()
TObjectPtr<UInputAction> IA_Move;
UPROPERTY()
TObjectPtr<UInputAction> IA_Look;
UPROPERTY()
TObjectPtr<UInputAction> IA_Scroll;
UPROPERTY()
TObjectPtr<UInputAction> IA_LeftClick;
UPROPERTY()
TObjectPtr<UInputAction> IA_RightClick;
UPROPERTY()
TObjectPtr<UInputAction> IA_MiddleClick;
UPROPERTY()
TObjectPtr<UInputAction> IA_TranslateMode;
UPROPERTY()
TObjectPtr<UInputAction> IA_RotateMode;
UPROPERTY()
TObjectPtr<UInputAction> IA_ScaleMode;
UPROPERTY()
TObjectPtr<UInputAction> IA_Delete;
UPROPERTY()
TObjectPtr<UInputAction> IA_SnapToGround;
UPROPERTY()
TObjectPtr<UInputAction> IA_Undo;
UPROPERTY()
TObjectPtr<UInputAction> IA_Redo;
// ---- Modifier key state ----
bool bAltHeld = false;
bool bCtrlHeld = false;
// ---- Click vs drag state ----
FVector2D PendingClickScreenPos = FVector2D::ZeroVector;
// ---- Gizmo drag state ----
EPS_Editor_GizmoAxis ActiveGizmoAxis = EPS_Editor_GizmoAxis::None;
FVector GizmoDragPlaneNormal = FVector::ZeroVector;
FVector GizmoDragPlaneOrigin = FVector::ZeroVector;
FVector GizmoDragInitialIntersection = FVector::ZeroVector;
TArray<FTransform> GizmoDragInitialTransforms;
float GizmoDragAccumulator = 0.0f;
// ---- Input Handlers ----
void HandleMove(const FInputActionValue& Value);
void HandleLook(const FInputActionValue& Value);
void HandleScroll(const FInputActionValue& Value);
void HandleLeftClickStarted(const FInputActionValue& Value);
void HandleLeftClickCompleted(const FInputActionValue& Value);
void HandleRightClickStarted(const FInputActionValue& Value);
void HandleRightClickCompleted(const FInputActionValue& Value);
void HandleMiddleClickStarted(const FInputActionValue& Value);
void HandleMiddleClickCompleted(const FInputActionValue& Value);
void HandleTranslateMode(const FInputActionValue& Value);
void HandleRotateMode(const FInputActionValue& Value);
void HandleScaleMode(const FInputActionValue& Value);
void HandleDelete(const FInputActionValue& Value);
void HandleSnapToGround(const FInputActionValue& Value);
void HandleUndo(const FInputActionValue& Value);
void HandleRedo(const FInputActionValue& Value);
// ---- Helpers ----
void CreateInputActions();
void SetCameraMode(EPS_Editor_CameraMode NewMode);
void UpdateCursorVisibility();
void ComputeOrbitFocalPoint();
FVector2D GetMouseScreenPosition() const;
bool GetMouseWorldRay(FVector& OutOrigin, FVector& OutDirection) const;
bool RayPlaneIntersection(const FVector& RayOrigin, const FVector& RayDir, const FVector& PlaneOrigin, const FVector& PlaneNormal, FVector& OutIntersection) const;
void UpdateGizmoHover();
void BeginGizmoDrag(EPS_Editor_GizmoAxis Axis);
float OrbitPitch = 0.0f;
float OrbitYaw = 0.0f;
};

View File

@@ -0,0 +1,89 @@
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_Pawn.h"
APS_Editor_PlayerController::APS_Editor_PlayerController()
{
bShowMouseCursor = true;
DefaultMouseCursor = EMouseCursor::Default;
}
void APS_Editor_PlayerController::BeginPlay()
{
Super::BeginPlay();
SetInputMode(FInputModeGameAndUI().SetHideCursorDuringCapture(false));
// Create selection manager
SelectionManager = NewObject<UPS_Editor_SelectionManager>(this);
SelectionManager->Initialize(this);
UndoManager = NewObject<UPS_Editor_UndoManager>(this);
// Load spawn catalog and create spawn manager
SpawnManager = NewObject<UPS_Editor_SpawnManager>(this);
UPS_Editor_SpawnCatalog* LoadedCatalog = SpawnCatalogAsset.LoadSynchronous();
SpawnManager->Initialize(this, LoadedCatalog);
SceneSerializer = NewObject<UPS_Editor_SceneSerializer>(this);
SceneSerializer->SetSelectionManager(SelectionManager);
// After undo/redo, deselect hidden actors and refresh gizmo
UndoManager->OnUndoRedo.AddWeakLambda(this, [this]()
{
if (SelectionManager)
{
// Deselect any actors that are now hidden (undone spawn or redone delete)
TArray<TWeakObjectPtr<AActor>> ToDeselect;
for (const TWeakObjectPtr<AActor>& Weak : SelectionManager->GetSelectedActors())
{
if (AActor* Actor = Weak.Get())
{
if (Actor->IsHidden())
{
ToDeselect.Add(Actor);
}
}
}
for (const TWeakObjectPtr<AActor>& Weak : ToDeselect)
{
if (AActor* Actor = Weak.Get())
{
SelectionManager->DeselectActor(Actor);
}
}
// Refresh gizmo position
if (SelectionManager->IsAnythingSelected())
{
if (APS_Editor_Gizmo* Gizmo = SelectionManager->GetGizmo())
{
Gizmo->SetActorLocation(SelectionManager->GetSelectionPivot());
}
}
}
});
}
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

@@ -0,0 +1,59 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "PS_Editor_PlayerController.generated.h"
class UPS_Editor_SelectionManager;
class UPS_Editor_UndoManager;
class UPS_Editor_SpawnManager;
class UPS_Editor_SpawnCatalog;
class UPS_Editor_SceneSerializer;
/**
* Player controller for PS_Editor runtime editing.
* Owns the SelectionManager and wires it to the Pawn's click delegate.
*/
UCLASS()
class PS_EDITOR_API APS_Editor_PlayerController : public APlayerController
{
GENERATED_BODY()
public:
APS_Editor_PlayerController();
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_SelectionManager* GetSelectionManager() const { return SelectionManager; }
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_UndoManager* GetUndoManager() const { return UndoManager; }
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_SpawnManager* GetSpawnManager() const { return SpawnManager; }
UFUNCTION(BlueprintCallable, Category = "PS Editor")
UPS_Editor_SceneSerializer* GetSceneSerializer() const { return SceneSerializer; }
/** The spawn catalog DataAsset to load. Set this in the Blueprint or World Settings. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TSoftObjectPtr<UPS_Editor_SpawnCatalog> SpawnCatalogAsset;
protected:
virtual void BeginPlay() override;
virtual void OnPossess(APawn* InPawn) override;
private:
UPROPERTY()
TObjectPtr<UPS_Editor_SelectionManager> SelectionManager;
UPROPERTY()
TObjectPtr<UPS_Editor_UndoManager> UndoManager;
UPROPERTY()
TObjectPtr<UPS_Editor_SpawnManager> SpawnManager;
UPROPERTY()
TObjectPtr<UPS_Editor_SceneSerializer> SceneSerializer;
void HandleEditorClick(FVector2D ScreenPos, bool bAdditive);
};

View File

@@ -0,0 +1,303 @@
#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;
};
// UE editor gizmo colors
Mat_X = MakeColorMat(FLinearColor(0.594f, 0.0197f, 0.0f)); // Dark red
Mat_Y = MakeColorMat(FLinearColor(0.1255f, 0.372f, 0.0f)); // Dark green
Mat_Z = MakeColorMat(FLinearColor(0.0392f, 0.0784f, 0.594f)); // Dark blue
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;
// Use a sphere sweep with a generous radius for easier clicking
const float SweepRadius = 8.0f * GetActorScale3D().X;
auto TestComponent = [&](UStaticMeshComponent* Comp, EPS_Editor_GizmoAxis Axis)
{
if (!Comp) return;
FHitResult Hit;
const FVector RayEnd = RayOrigin + RayDirection * 100000.0f;
const FCollisionShape Sphere = FCollisionShape::MakeSphere(SweepRadius);
if (Comp->SweepComponent(Hit, RayOrigin, RayEnd, FQuat::Identity, Sphere, false))
{
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

@@ -0,0 +1,45 @@
using UnrealBuildTool;
using System.IO;
public class PS_Editor : ModuleRules
{
public PS_Editor(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
// Add all subdirectories to include paths so headers can be included by name
string SourceDir = Path.Combine(ModuleDirectory);
PublicIncludePaths.Add(SourceDir);
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"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Spawn"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Serialization"));
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"InputCore",
"EnhancedInput",
"UMG",
"Json",
"JsonUtilities"
});
PrivateDependencyModuleNames.AddRange(new string[]
{
"Slate",
"SlateCore"
});
// Editor-only: Factory for SpawnCatalog DataAsset creation
if (Target.bBuildEditor)
{
PrivateDependencyModuleNames.Add("UnrealEd");
}
}
}

View File

@@ -0,0 +1,17 @@
#include "PS_Editor.h"
#define LOCTEXT_NAMESPACE "FPS_EditorModule"
void FPS_EditorModule::StartupModule()
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor module started."));
}
void FPS_EditorModule::ShutdownModule()
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor module shut down."));
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FPS_EditorModule, PS_Editor)

View File

@@ -0,0 +1,11 @@
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
class FPS_EditorModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};

View File

@@ -0,0 +1,301 @@
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.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::SnapSelectionToGround()
{
APlayerController* PC = OwnerPC.Get();
if (!PC) return;
// Capture transforms BEFORE snap for undo
TSharedPtr<FPS_Editor_TransformAction> UndoAction = MakeShared<FPS_Editor_TransformAction>();
UndoAction->Description = FString::Printf(TEXT("Snap to ground %d actor(s)"), SelectedActors.Num());
for (const TWeakObjectPtr<AActor>& Weak : SelectedActors)
{
AActor* Actor = Weak.Get();
if (!Actor) continue;
UndoAction->Actors.Add(Actor);
UndoAction->OldTransforms.Add(Actor->GetActorTransform());
FVector Origin, Extent;
Actor->GetActorBounds(false, Origin, Extent);
const float BottomOffset = Extent.Z;
const FVector Start = Actor->GetActorLocation();
const FVector End = Start - FVector::UpVector * 100000.0f;
FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(Actor);
Params.AddIgnoredActor(PC->GetPawn());
if (Gizmo) Params.AddIgnoredActor(Gizmo);
if (Actor->GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Visibility, Params))
{
FVector NewLocation = Hit.ImpactPoint;
NewLocation.Z += BottomOffset;
Actor->SetActorLocation(NewLocation);
}
UndoAction->NewTransforms.Add(Actor->GetActorTransform());
}
// Record undo action
if (UndoAction->Actors.Num() > 0)
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->RecordAction(UndoAction);
}
}
}
UpdateGizmo();
}
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,84 @@
#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();
/** Snap all selected actors to the ground below them. */
void SnapSelectionToGround();
// ---- 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
};

View File

@@ -0,0 +1,119 @@
#include "PS_Editor_UndoManager.h"
void UPS_Editor_UndoManager::RecordAction(TSharedPtr<FPS_Editor_Action> Action)
{
if (!Action.IsValid())
{
return;
}
// Discard any redo history (actions after CurrentIndex)
if (CurrentIndex < ActionStack.Num() - 1)
{
// Cleanup delete actions that will be discarded (truly destroy hidden actors)
for (int32 i = CurrentIndex + 1; i < ActionStack.Num(); ++i)
{
CleanupDeleteAction(ActionStack[i]);
}
ActionStack.SetNum(CurrentIndex + 1);
}
ActionStack.Add(Action);
CurrentIndex = ActionStack.Num() - 1;
PruneHistory();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Recorded action: %s (stack size: %d)"),
*Action->GetDescription(), ActionStack.Num());
}
void UPS_Editor_UndoManager::Undo()
{
if (!CanUndo())
{
return;
}
TSharedPtr<FPS_Editor_Action>& Action = ActionStack[CurrentIndex];
Action->Undo();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Undo: %s"), *Action->GetDescription());
CurrentIndex--;
OnUndoRedo.Broadcast();
}
void UPS_Editor_UndoManager::Redo()
{
if (!CanRedo())
{
return;
}
CurrentIndex++;
TSharedPtr<FPS_Editor_Action>& Action = ActionStack[CurrentIndex];
Action->Execute();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Redo: %s"), *Action->GetDescription());
OnUndoRedo.Broadcast();
}
FString UPS_Editor_UndoManager::GetUndoDescription() const
{
if (!CanUndo())
{
return TEXT("");
}
return ActionStack[CurrentIndex]->GetDescription();
}
FString UPS_Editor_UndoManager::GetRedoDescription() const
{
if (!CanRedo())
{
return TEXT("");
}
return ActionStack[CurrentIndex + 1]->GetDescription();
}
void UPS_Editor_UndoManager::PruneHistory()
{
while (ActionStack.Num() > MaxHistorySize)
{
// Cleanup the oldest action before removing
CleanupDeleteAction(ActionStack[0]);
ActionStack.RemoveAt(0);
CurrentIndex--;
}
}
void UPS_Editor_UndoManager::CleanupDeleteAction(TSharedPtr<FPS_Editor_Action> Action)
{
if (!Action.IsValid()) return;
// Truly destroy hidden actors when their action is pruned from history
TArray<TWeakObjectPtr<AActor>>* ActorsToClean = nullptr;
if (Action->ActionType == EPS_Editor_ActionType::Delete)
{
ActorsToClean = &static_cast<FPS_Editor_DeleteAction*>(Action.Get())->Actors;
}
else if (Action->ActionType == EPS_Editor_ActionType::Spawn)
{
ActorsToClean = &static_cast<FPS_Editor_SpawnAction*>(Action.Get())->Actors;
}
if (ActorsToClean)
{
for (const TWeakObjectPtr<AActor>& Weak : *ActorsToClean)
{
if (AActor* Actor = Weak.Get())
{
if (Actor->IsHidden())
{
Actor->Destroy();
}
}
}
}
}

View File

@@ -0,0 +1,204 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "PS_Editor_UndoManager.generated.h"
enum class EPS_Editor_ActionType : uint8
{
Transform,
Delete,
Spawn
};
/**
* Base class for undoable editor actions.
*/
struct FPS_Editor_Action
{
EPS_Editor_ActionType ActionType;
explicit FPS_Editor_Action(EPS_Editor_ActionType InType) : ActionType(InType) {}
virtual ~FPS_Editor_Action() = default;
virtual void Execute() = 0;
virtual void Undo() = 0;
virtual FString GetDescription() const = 0;
};
/**
* Records a transform change on one or more actors.
* Used for: gizmo translate/rotate/scale, snap to ground, UI transform edit.
*/
struct FPS_Editor_TransformAction : public FPS_Editor_Action
{
FPS_Editor_TransformAction() : FPS_Editor_Action(EPS_Editor_ActionType::Transform) {}
TArray<TWeakObjectPtr<AActor>> Actors;
TArray<FTransform> OldTransforms;
TArray<FTransform> NewTransforms;
FString Description;
virtual void Execute() override
{
for (int32 i = 0; i < Actors.Num(); ++i)
{
if (AActor* Actor = Actors[i].Get())
{
if (i < NewTransforms.Num())
{
Actor->SetActorTransform(NewTransforms[i]);
}
}
}
}
virtual void Undo() override
{
for (int32 i = 0; i < Actors.Num(); ++i)
{
if (AActor* Actor = Actors[i].Get())
{
if (i < OldTransforms.Num())
{
Actor->SetActorTransform(OldTransforms[i]);
}
}
}
}
virtual FString GetDescription() const override { return Description; }
};
/**
* Records a soft-delete action (hide + disable collision).
* Undo re-shows the actor. Actors are truly destroyed when the action is pruned from the stack.
*/
struct FPS_Editor_DeleteAction : public FPS_Editor_Action
{
FPS_Editor_DeleteAction() : FPS_Editor_Action(EPS_Editor_ActionType::Delete) {}
TArray<TWeakObjectPtr<AActor>> Actors;
virtual void Execute() override
{
for (const TWeakObjectPtr<AActor>& Weak : Actors)
{
if (AActor* Actor = Weak.Get())
{
Actor->SetActorHiddenInGame(true);
Actor->SetActorEnableCollision(false);
Actor->SetActorTickEnabled(false);
}
}
}
virtual void Undo() override
{
for (const TWeakObjectPtr<AActor>& Weak : Actors)
{
if (AActor* Actor = Weak.Get())
{
Actor->SetActorHiddenInGame(false);
Actor->SetActorEnableCollision(true);
Actor->SetActorTickEnabled(true);
}
}
}
virtual FString GetDescription() const override
{
return FString::Printf(TEXT("Delete %d actor(s)"), Actors.Num());
}
};
/**
* Records a spawn action. Undo hides the spawned actor, redo shows it again.
*/
struct FPS_Editor_SpawnAction : public FPS_Editor_Action
{
FPS_Editor_SpawnAction() : FPS_Editor_Action(EPS_Editor_ActionType::Spawn) {}
TArray<TWeakObjectPtr<AActor>> Actors;
virtual void Execute() override
{
// Redo: show the spawned actors
for (const TWeakObjectPtr<AActor>& Weak : Actors)
{
if (AActor* Actor = Weak.Get())
{
Actor->SetActorHiddenInGame(false);
Actor->SetActorEnableCollision(true);
Actor->SetActorTickEnabled(true);
}
}
}
virtual void Undo() override
{
// Undo spawn: hide the actors
for (const TWeakObjectPtr<AActor>& Weak : Actors)
{
if (AActor* Actor = Weak.Get())
{
Actor->SetActorHiddenInGame(true);
Actor->SetActorEnableCollision(false);
Actor->SetActorTickEnabled(false);
}
}
}
virtual FString GetDescription() const override
{
return FString::Printf(TEXT("Spawn %d actor(s)"), Actors.Num());
}
};
/**
* Runtime undo/redo manager for PS_Editor.
* Maintains a linear action stack with a cursor.
* When a new action is recorded after undoing, all redo history is discarded.
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_UndoManager : public UObject
{
GENERATED_BODY()
public:
/** Record a new undoable action. Discards any redo history. */
void RecordAction(TSharedPtr<FPS_Editor_Action> Action);
/** Undo the last action. */
void Undo();
/** Redo the next action. */
void Redo();
bool CanUndo() const { return CurrentIndex >= 0; }
bool CanRedo() const { return CurrentIndex < ActionStack.Num() - 1; }
/** Get description of the action that would be undone. */
FString GetUndoDescription() const;
/** Get description of the action that would be redone. */
FString GetRedoDescription() const;
/** Called after any undo/redo to update gizmo, UI, etc. */
DECLARE_MULTICAST_DELEGATE(FOnUndoRedo);
FOnUndoRedo OnUndoRedo;
/** Maximum number of actions to keep in history. */
int32 MaxHistorySize = 50;
private:
TArray<TSharedPtr<FPS_Editor_Action>> ActionStack;
/** Points to the last executed action. -1 means no actions executed. */
int32 CurrentIndex = -1;
/** Prune oldest actions if stack exceeds MaxHistorySize. */
void PruneHistory();
/** Destroy actors from pruned delete actions. */
void CleanupDeleteAction(TSharedPtr<FPS_Editor_Action> Action);
};

View File

@@ -0,0 +1,43 @@
#pragma once
#include "CoreMinimal.h"
#include "PS_Editor_SceneData.generated.h"
/** Serialized data for a single spawned actor. */
USTRUCT()
struct FPS_Editor_ActorData
{
GENERATED_BODY()
/** Class path (e.g. "/Game/BP_Cube.BP_Cube_C"). */
UPROPERTY()
FString ClassPath;
UPROPERTY()
FVector Location = FVector::ZeroVector;
UPROPERTY()
FRotator Rotation = FRotator::ZeroRotator;
UPROPERTY()
FVector Scale = FVector::OneVector;
};
/** Serialized data for a complete scene. */
USTRUCT()
struct FPS_Editor_SceneData
{
GENERATED_BODY()
UPROPERTY()
int32 Version = 1;
UPROPERTY()
FString SceneName;
UPROPERTY()
FString Timestamp;
UPROPERTY()
TArray<FPS_Editor_ActorData> Actors;
};

View File

@@ -0,0 +1,165 @@
#include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SelectionManager.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "HAL/PlatformFileManager.h"
bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager)
{
if (!SpawnManager || SceneName.IsEmpty())
{
return false;
}
// Build scene data from currently spawned actors
FPS_Editor_SceneData SceneData;
SceneData.SceneName = SceneName;
SceneData.Timestamp = FDateTime::Now().ToString();
const TArray<TWeakObjectPtr<AActor>>& SpawnedActors = SpawnManager->GetSpawnedActors();
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{
AActor* Actor = Weak.Get();
if (!Actor || Actor->IsHidden())
{
continue; // Skip destroyed or soft-deleted actors
}
FPS_Editor_ActorData ActorData;
ActorData.ClassPath = Actor->GetClass()->GetPathName();
ActorData.Location = Actor->GetActorLocation();
ActorData.Rotation = Actor->GetActorRotation();
ActorData.Scale = Actor->GetActorScale3D();
SceneData.Actors.Add(ActorData);
}
// Convert to JSON
FString JsonString;
if (!FJsonObjectConverter::UStructToJsonObjectString(SceneData, JsonString, 0, 0, 0, nullptr, true))
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to serialize scene to JSON"));
return false;
}
// Ensure directory exists
const FString Directory = GetScenesDirectory();
IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
if (!PlatformFile.DirectoryExists(*Directory))
{
PlatformFile.CreateDirectoryTree(*Directory);
}
// Write file
const FString FilePath = GetSceneFilePath(SceneName);
if (!FFileHelper::SaveStringToFile(JsonString, *FilePath))
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to write scene file: %s"), *FilePath);
return false;
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene saved: %s (%d actors) -> %s"), *SceneName, SceneData.Actors.Num(), *FilePath);
return true;
}
bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager)
{
if (!SpawnManager || SceneName.IsEmpty())
{
return false;
}
// Read file
const FString FilePath = GetSceneFilePath(SceneName);
FString JsonString;
if (!FFileHelper::LoadFileToString(JsonString, *FilePath))
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to read scene file: %s"), *FilePath);
return false;
}
// Parse JSON
FPS_Editor_SceneData SceneData;
if (!FJsonObjectConverter::JsonObjectStringToUStruct(JsonString, &SceneData, 0, 0))
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: Failed to parse scene JSON"));
return false;
}
// Clear existing spawned actors
ClearScene(SpawnManager);
// Re-spawn actors from saved data
int32 SpawnedCount = 0;
for (const FPS_Editor_ActorData& ActorData : SceneData.Actors)
{
UClass* LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class: %s"), *ActorData.ClassPath);
continue;
}
AActor* SpawnedActor = SpawnManager->SpawnActorDirect(LoadedClass, ActorData.Location, ActorData.Rotation, ActorData.Scale);
if (SpawnedActor)
{
SpawnedCount++;
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene loaded: %s (%d actors)"), *SceneName, SpawnedCount);
return true;
}
TArray<FString> UPS_Editor_SceneSerializer::GetSavedSceneNames() const
{
TArray<FString> SceneNames;
const FString Directory = GetScenesDirectory();
TArray<FString> Files;
IFileManager::Get().FindFiles(Files, *FPaths::Combine(Directory, TEXT("*.json")), true, false);
for (const FString& File : Files)
{
SceneNames.Add(FPaths::GetBaseFilename(File));
}
SceneNames.Sort();
return SceneNames;
}
bool UPS_Editor_SceneSerializer::DeleteScene(const FString& SceneName)
{
const FString FilePath = GetSceneFilePath(SceneName);
if (IFileManager::Get().Delete(*FilePath))
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene deleted: %s"), *SceneName);
return true;
}
return false;
}
void UPS_Editor_SceneSerializer::ClearScene(UPS_Editor_SpawnManager* SpawnManager)
{
if (UPS_Editor_SelectionManager* SM = SelectionManagerRef.Get())
{
SM->ClearSelection();
}
if (SpawnManager)
{
SpawnManager->DestroyAllSpawnedActors();
}
}
FString UPS_Editor_SceneSerializer::GetSceneFilePath(const FString& SceneName) const
{
return FPaths::Combine(GetScenesDirectory(), SceneName + TEXT(".json"));
}
FString UPS_Editor_SceneSerializer::GetScenesDirectory() const
{
return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("PS_Editor"), TEXT("Scenes"));
}

View File

@@ -0,0 +1,48 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "PS_Editor_SceneData.h"
#include "PS_Editor_SceneSerializer.generated.h"
class UPS_Editor_SpawnManager;
class UPS_Editor_SelectionManager;
/**
* Handles saving and loading PS_Editor scenes as JSON files.
* Scenes are stored in {ProjectDir}/Saved/PS_Editor/Scenes/{SceneName}.json
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_SceneSerializer : public UObject
{
GENERATED_BODY()
public:
/** Save the current spawned actors to a JSON file. */
bool SaveScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager);
/** Load a scene from a JSON file. Destroys current spawned actors and re-spawns from file. */
bool LoadScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager);
/** Get a list of all saved scene names (without .json extension). */
TArray<FString> GetSavedSceneNames() const;
/** Delete a saved scene file. */
bool DeleteScene(const FString& SceneName);
/** Clear all spawned actors (new scene). Also clears selection and undo history. */
void ClearScene(UPS_Editor_SpawnManager* SpawnManager);
/** Set optional selection manager to clear on load/new. */
void SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager) { SelectionManagerRef = InSelectionManager; }
private:
UPROPERTY()
TWeakObjectPtr<UPS_Editor_SelectionManager> SelectionManagerRef;
/** Get the full file path for a scene name. */
FString GetSceneFilePath(const FString& SceneName) const;
/** Get the directory where scenes are stored. */
FString GetScenesDirectory() const;
};

View File

@@ -0,0 +1,113 @@
#include "PS_Editor_SpawnCatalog.h"
#if WITH_EDITOR
#include "PS_Editor_SpawnableComponent.h"
#include "Engine/Blueprint.h"
#include "Engine/Texture2D.h"
#include "ThumbnailRendering/ThumbnailManager.h"
#include "ObjectTools.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "UObject/SavePackage.h"
#include "Misc/PackageName.h"
void UPS_Editor_SpawnCatalog::CaptureAllThumbnails()
{
int32 Captured = 0;
for (FPS_Editor_SpawnEntry& Entry : Entries)
{
UClass* LoadedClass = Entry.ActorClass.LoadSynchronous();
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class for catalog entry"));
continue;
}
// Find the SpawnableComponent template
UPS_Editor_SpawnableComponent* SpawnComp = nullptr;
TArray<UObject*> ClassSubObjects;
GetObjectsWithOuter(LoadedClass, ClassSubObjects, true);
for (UObject* Sub : ClassSubObjects)
{
SpawnComp = Cast<UPS_Editor_SpawnableComponent>(Sub);
if (SpawnComp) break;
}
if (!SpawnComp)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: No SpawnableComponent on %s, skipping"), *LoadedClass->GetName());
continue;
}
// Skip if already has a custom thumbnail
if (SpawnComp->Thumbnail)
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor: %s already has a thumbnail, skipping"), *LoadedClass->GetName());
continue;
}
// Find the Blueprint
UBlueprint* Blueprint = Cast<UBlueprint>(LoadedClass->ClassGeneratedBy);
if (!Blueprint)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: %s is not a Blueprint, skipping"), *LoadedClass->GetName());
continue;
}
// Generate thumbnail
FObjectThumbnail* ObjThumb = ThumbnailTools::GenerateThumbnailForObjectToSaveToDisk(Blueprint);
if (!ObjThumb || ObjThumb->GetImageWidth() <= 0 || ObjThumb->GetImageHeight() <= 0)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to generate thumbnail for %s"), *Blueprint->GetName());
continue;
}
const int32 Width = ObjThumb->GetImageWidth();
const int32 Height = ObjThumb->GetImageHeight();
const TArray<uint8>& PixelData = ObjThumb->GetUncompressedImageData();
if (PixelData.Num() != Width * Height * 4)
{
continue;
}
// Save texture next to the Blueprint
FString BlueprintPath = Blueprint->GetOutermost()->GetName();
FString BasePath = FPaths::GetPath(BlueprintPath);
FString AssetName = FString::Printf(TEXT("T_%s_Thumb"), *Blueprint->GetName());
FString FullPath = FString::Printf(TEXT("%s/%s"), *BasePath, *AssetName);
UPackage* Package = CreatePackage(*FullPath);
if (!Package) continue;
UTexture2D* Texture = NewObject<UTexture2D>(Package, *AssetName, RF_Public | RF_Standalone);
Texture->Source.Init(Width, Height, 1, 1, TSF_BGRA8);
uint8* MipData = Texture->Source.LockMip(0);
FMemory::Memcpy(MipData, PixelData.GetData(), PixelData.Num());
Texture->Source.UnlockMip(0);
Texture->SRGB = true;
Texture->CompressionSettings = TC_Default;
Texture->MipGenSettings = TMGS_NoMipmaps;
Texture->PostEditChange();
Texture->UpdateResource();
FString PackageFilename = FPackageName::LongPackageNameToFilename(FullPath, FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = RF_Public | RF_Standalone;
UPackage::SavePackage(Package, Texture, *PackageFilename, SaveArgs);
FAssetRegistryModule::AssetCreated(Texture);
// Assign to the component and mark Blueprint dirty
SpawnComp->Thumbnail = Texture;
Blueprint->MarkPackageDirty();
Captured++;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Captured thumbnail for %s -> %s"), *Blueprint->GetName(), *FullPath);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Captured %d thumbnails"), Captured);
}
#endif

View File

@@ -0,0 +1,45 @@
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "Engine/AssetManager.h"
#include "PS_Editor_SpawnCatalog.generated.h"
/**
* A single entry in the spawn catalogue.
*/
USTRUCT(BlueprintType)
struct FPS_Editor_SpawnEntry
{
GENERATED_BODY()
/** The Blueprint class to spawn. Must have a PS_Editor_SpawnableComponent for name/category. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TSoftClassPtr<AActor> ActorClass;
};
/**
* Data asset listing all Blueprints available for spawning in the PS_Editor runtime catalogue.
*
* Usage:
* 1. Create a DataAsset of type PS_Editor_SpawnCatalog in Content Browser
* 2. Add entries pointing to your prepared Blueprints
* 3. Click "Capture All Thumbnails" to auto-generate thumbnails
* 4. Reference this DataAsset in the PlayerController's SpawnCatalogAsset property
*/
UCLASS(BlueprintType)
class PS_EDITOR_API UPS_Editor_SpawnCatalog : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
/** List of spawnable actors. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TArray<FPS_Editor_SpawnEntry> Entries;
#if WITH_EDITOR
/** Captures UE-generated thumbnails for all Blueprints in the catalog and assigns them to their SpawnableComponents. */
UFUNCTION(CallInEditor, Category = "PS Editor")
void CaptureAllThumbnails();
#endif
};

View File

@@ -0,0 +1,27 @@
#if WITH_EDITOR
#include "PS_Editor_SpawnCatalogFactory.h"
#include "PS_Editor_SpawnCatalog.h"
#define LOCTEXT_NAMESPACE "PS_Editor"
UPS_Editor_SpawnCatalogFactory::UPS_Editor_SpawnCatalogFactory()
{
SupportedClass = UPS_Editor_SpawnCatalog::StaticClass();
bCreateNew = true;
bEditAfterNew = true;
}
UObject* UPS_Editor_SpawnCatalogFactory::FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn)
{
return NewObject<UPS_Editor_SpawnCatalog>(InParent, InClass, InName, Flags);
}
FText UPS_Editor_SpawnCatalogFactory::GetDisplayName() const
{
return LOCTEXT("SpawnCatalogDisplayName", "PS Editor Spawn Catalog");
}
#undef LOCTEXT_NAMESPACE
#endif

View File

@@ -0,0 +1,22 @@
#pragma once
#if WITH_EDITOR
#include "CoreMinimal.h"
#include "Factories/Factory.h"
#include "PS_Editor_SpawnCatalogFactory.generated.h"
UCLASS(HideCategories = Object)
class UPS_Editor_SpawnCatalogFactory : public UFactory
{
GENERATED_BODY()
public:
UPS_Editor_SpawnCatalogFactory();
virtual UObject* FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
virtual bool ShouldShowInNewMenu() const override { return true; }
virtual FText GetDisplayName() const override;
};
#endif

View File

@@ -0,0 +1,215 @@
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
void UPS_Editor_SpawnManager::Initialize(APlayerController* InOwnerPC, UPS_Editor_SpawnCatalog* InCatalog)
{
OwnerPC = InOwnerPC;
Catalog = InCatalog;
if (Catalog)
{
ResolveCatalog();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: SpawnCatalog loaded with %d entries"), ResolvedEntries.Num());
}
else
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: No SpawnCatalog assigned. Spawn catalogue will be empty."));
}
}
void UPS_Editor_SpawnManager::ResolveCatalog()
{
ResolvedEntries.Empty();
if (!Catalog)
{
return;
}
for (const FPS_Editor_SpawnEntry& Entry : Catalog->Entries)
{
// Synchronously load the class
UClass* LoadedClass = Entry.ActorClass.LoadSynchronous();
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class for catalog entry"));
continue;
}
FPS_Editor_ResolvedEntry Resolved;
Resolved.ActorClass = LoadedClass;
// Find the SpawnableComponent template in the Blueprint class hierarchy.
// Blueprint-added components are stored as subobjects of the UBlueprintGeneratedClass.
UPS_Editor_SpawnableComponent* SpawnComp = nullptr;
// Search BPGC subobjects (Blueprint-added components)
TArray<UObject*> ClassSubObjects;
GetObjectsWithOuter(LoadedClass, ClassSubObjects, true);
for (UObject* Sub : ClassSubObjects)
{
SpawnComp = Cast<UPS_Editor_SpawnableComponent>(Sub);
if (SpawnComp)
{
break;
}
}
// Fallback: check CDO subobjects (C++ constructor components)
if (!SpawnComp)
{
AActor* CDO = LoadedClass->GetDefaultObject<AActor>();
if (CDO)
{
SpawnComp = CDO->FindComponentByClass<UPS_Editor_SpawnableComponent>();
}
}
if (SpawnComp && !SpawnComp->DisplayName.IsEmpty())
{
Resolved.DisplayName = SpawnComp->DisplayName;
}
else
{
Resolved.DisplayName = LoadedClass->GetName();
Resolved.DisplayName.RemoveFromEnd(TEXT("_C"));
}
Resolved.Category = SpawnComp ? SpawnComp->Category : TEXT("Default");
Resolved.Thumbnail = SpawnComp ? SpawnComp->Thumbnail : nullptr;
ResolvedEntries.Add(Resolved);
}
}
AActor* UPS_Editor_SpawnManager::SpawnFromCatalog(int32 Index)
{
return SpawnFromCatalogAtLocation(Index, ComputeSpawnLocation());
}
AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector WorldLocation)
{
if (!ResolvedEntries.IsValidIndex(Index))
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Invalid catalog index %d"), Index);
return nullptr;
}
APlayerController* PC = OwnerPC.Get();
if (!PC || !PC->GetWorld())
{
return nullptr;
}
const FPS_Editor_ResolvedEntry& Entry = ResolvedEntries[Index];
if (!Entry.ActorClass)
{
return nullptr;
}
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
AActor* SpawnedActor = PC->GetWorld()->SpawnActor<AActor>(Entry.ActorClass, WorldLocation, FRotator::ZeroRotator, SpawnParams);
if (!SpawnedActor)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to spawn %s"), *Entry.DisplayName);
return nullptr;
}
// Ensure the spawned actor is Movable (so it's selectable)
if (USceneComponent* Root = SpawnedActor->GetRootComponent())
{
Root->SetMobility(EComponentMobility::Movable);
}
// Record undo action (undo = hide the spawned actor)
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
TSharedPtr<FPS_Editor_SpawnAction> Action = MakeShared<FPS_Editor_SpawnAction>();
Action->Actors.Add(SpawnedActor);
UM->RecordAction(Action);
}
}
// Track spawned actor
SpawnedActors.Add(SpawnedActor);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Spawned %s at %s"), *Entry.DisplayName, *WorldLocation.ToString());
return SpawnedActor;
}
FVector UPS_Editor_SpawnManager::ComputeSpawnLocation() const
{
APlayerController* PC = OwnerPC.Get();
if (!PC)
{
return FVector::ZeroVector;
}
FVector CameraLocation;
FRotator CameraRotation;
PC->GetPlayerViewPoint(CameraLocation, CameraRotation);
// Spawn 500 units in front of the camera
return CameraLocation + CameraRotation.Vector() * 500.0f;
}
AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Location, FRotator Rotation, FVector Scale)
{
APlayerController* PC = OwnerPC.Get();
if (!PC || !PC->GetWorld() || !ActorClass)
{
return nullptr;
}
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
AActor* SpawnedActor = PC->GetWorld()->SpawnActor<AActor>(ActorClass, Location, Rotation, SpawnParams);
if (!SpawnedActor)
{
return nullptr;
}
if (USceneComponent* Root = SpawnedActor->GetRootComponent())
{
Root->SetMobility(EComponentMobility::Movable);
}
SpawnedActor->SetActorScale3D(Scale);
SpawnedActors.Add(SpawnedActor);
return SpawnedActor;
}
void UPS_Editor_SpawnManager::DestroyAllSpawnedActors()
{
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{
if (AActor* Actor = Weak.Get())
{
Actor->Destroy();
}
}
SpawnedActors.Empty();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: All spawned actors destroyed"));
}
TArray<FString> UPS_Editor_SpawnManager::GetCategories() const
{
TArray<FString> Categories;
for (const FPS_Editor_ResolvedEntry& Entry : ResolvedEntries)
{
Categories.AddUnique(Entry.Category);
}
Categories.Sort();
return Categories;
}

View File

@@ -0,0 +1,82 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "PS_Editor_SpawnCatalog.h"
#include "PS_Editor_SpawnManager.generated.h"
class APlayerController;
class UPS_Editor_UndoManager;
/**
* Resolved catalog entry with display info ready for UI.
*/
USTRUCT()
struct FPS_Editor_ResolvedEntry
{
GENERATED_BODY()
UPROPERTY()
TSubclassOf<AActor> ActorClass;
FString DisplayName;
FString Category;
UPROPERTY()
TObjectPtr<UTexture2D> Thumbnail;
};
/**
* Manages spawning of prepared Blueprint actors at runtime.
* Loads a SpawnCatalog DataAsset and provides spawn functions.
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_SpawnManager : public UObject
{
GENERATED_BODY()
public:
/** Initialize with owning controller and load catalog. */
void Initialize(APlayerController* InOwnerPC, UPS_Editor_SpawnCatalog* InCatalog);
/** Spawn the actor at the given catalog index, positioned in front of the camera. Returns the spawned actor. */
AActor* SpawnFromCatalog(int32 Index);
/** Spawn the actor at a specific world location. */
AActor* SpawnFromCatalogAtLocation(int32 Index, FVector WorldLocation);
/** Spawn an actor directly from a class (used by scene loading). No undo recorded. */
AActor* SpawnActorDirect(UClass* ActorClass, FVector Location, FRotator Rotation, FVector Scale);
/** Destroy all spawned actors (for scene clear/load). */
void DestroyAllSpawnedActors();
/** Get all currently spawned actors. */
const TArray<TWeakObjectPtr<AActor>>& GetSpawnedActors() const { return SpawnedActors; }
/** Get the resolved catalog entries for UI display. */
const TArray<FPS_Editor_ResolvedEntry>& GetResolvedEntries() const { return ResolvedEntries; }
/** Get unique categories for filtering. */
TArray<FString> GetCategories() const;
private:
UPROPERTY()
TWeakObjectPtr<APlayerController> OwnerPC;
UPROPERTY()
TObjectPtr<UPS_Editor_SpawnCatalog> Catalog;
UPROPERTY()
TArray<FPS_Editor_ResolvedEntry> ResolvedEntries;
/** All actors spawned by this manager (tracked for save/load). */
UPROPERTY()
TArray<TWeakObjectPtr<AActor>> SpawnedActors;
/** Resolve all catalog entries (load classes, read SpawnableComponent metadata). */
void ResolveCatalog();
/** Compute spawn location in front of the camera. */
FVector ComputeSpawnLocation() const;
};

View File

@@ -0,0 +1,6 @@
#include "PS_Editor_SpawnableComponent.h"
UPS_Editor_SpawnableComponent::UPS_Editor_SpawnableComponent()
{
PrimaryComponentTick.bCanEverTick = false;
}

View File

@@ -0,0 +1,30 @@
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "PS_Editor_SpawnableComponent.generated.h"
/**
* Marker component for Blueprints that can be spawned via the PS_Editor catalogue.
* Add this component to any Blueprint to make it appear in the runtime spawn catalogue.
*/
UCLASS(ClassGroup = (PSEditor), meta = (BlueprintSpawnableComponent, DisplayName = "PS Editor Spawnable"))
class PS_EDITOR_API UPS_Editor_SpawnableComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPS_Editor_SpawnableComponent();
/** Display name shown in the spawn catalogue UI. If empty, uses the actor's class name. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
FString DisplayName;
/** Category for filtering in the catalogue (e.g. "Characters", "Props", "Vehicles"). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
FString Category = TEXT("Default");
/** Optional thumbnail texture shown in the catalogue. Use "Capture All Thumbnails" on the SpawnCatalog DataAsset to auto-generate. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
TObjectPtr<UTexture2D> Thumbnail;
};

View File

@@ -0,0 +1,139 @@
#if WITH_EDITOR
#include "PS_Editor_SpawnableComponentCustomization.h"
#include "PS_Editor_SpawnableComponent.h"
#include "DetailLayoutBuilder.h"
#include "DetailCategoryBuilder.h"
#include "DetailWidgetRow.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Text/STextBlock.h"
#include "Engine/Blueprint.h"
#include "Engine/Texture2D.h"
#include "ThumbnailRendering/ThumbnailManager.h"
#include "ObjectTools.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "UObject/SavePackage.h"
#include "Misc/PackageName.h"
#include "HAL/FileManager.h"
TSharedRef<IDetailCustomization> FPS_Editor_SpawnableComponentCustomization::MakeInstance()
{
return MakeShareable(new FPS_Editor_SpawnableComponentCustomization);
}
void FPS_Editor_SpawnableComponentCustomization::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder)
{
TArray<TWeakObjectPtr<UObject>> Objects;
DetailBuilder.GetObjectsBeingCustomized(Objects);
if (Objects.Num() > 0)
{
EditedComponent = Objects[0];
}
IDetailCategoryBuilder& Category = DetailBuilder.EditCategory("PS Editor|Spawnable");
Category.AddCustomRow(FText::FromString(TEXT("Capture Thumbnail")))
.NameContent()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Auto Thumbnail")))
.Font(IDetailLayoutBuilder::GetDetailFont())
]
.ValueContent()
.MinDesiredWidth(150.0f)
[
SNew(SButton)
.HAlign(HAlign_Center)
.OnClicked(FOnClicked::CreateRaw(this, &FPS_Editor_SpawnableComponentCustomization::OnCaptureThumbnailClicked))
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Capture Thumbnail")))
.Font(IDetailLayoutBuilder::GetDetailFont())
]
];
}
FReply FPS_Editor_SpawnableComponentCustomization::OnCaptureThumbnailClicked()
{
UPS_Editor_SpawnableComponent* Comp = Cast<UPS_Editor_SpawnableComponent>(EditedComponent.Get());
if (!Comp)
{
return FReply::Handled();
}
// Find the owning Blueprint
AActor* OwnerActor = Comp->GetOwner();
if (!OwnerActor)
{
return FReply::Handled();
}
UClass* ActorClass = OwnerActor->GetClass();
UBlueprint* Blueprint = Cast<UBlueprint>(ActorClass->ClassGeneratedBy);
if (!Blueprint)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Cannot capture thumbnail - not a Blueprint actor"));
return FReply::Handled();
}
// Generate thumbnail
FObjectThumbnail* ObjThumb = ThumbnailTools::GenerateThumbnailForObjectToSaveToDisk(Blueprint);
if (!ObjThumb || ObjThumb->GetImageWidth() <= 0 || ObjThumb->GetImageHeight() <= 0)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to generate thumbnail for %s"), *Blueprint->GetName());
return FReply::Handled();
}
const int32 Width = ObjThumb->GetImageWidth();
const int32 Height = ObjThumb->GetImageHeight();
const TArray<uint8>& PixelData = ObjThumb->GetUncompressedImageData();
if (PixelData.Num() != Width * Height * 4)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Invalid thumbnail pixel data"));
return FReply::Handled();
}
// Save as a persistent texture asset next to the Blueprint
FString BlueprintPath = Blueprint->GetOutermost()->GetName();
FString BasePath = FPaths::GetPath(BlueprintPath);
FString AssetName = FString::Printf(TEXT("T_%s_Thumb"), *Blueprint->GetName());
FString FullPath = FString::Printf(TEXT("%s/%s"), *BasePath, *AssetName);
UPackage* Package = CreatePackage(*FullPath);
if (!Package)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to create package for thumbnail"));
return FReply::Handled();
}
UTexture2D* Texture = NewObject<UTexture2D>(Package, *AssetName, RF_Public | RF_Standalone);
Texture->Source.Init(Width, Height, 1, 1, TSF_BGRA8);
uint8* MipData = Texture->Source.LockMip(0);
FMemory::Memcpy(MipData, PixelData.GetData(), PixelData.Num());
Texture->Source.UnlockMip(0);
Texture->SRGB = true;
Texture->CompressionSettings = TC_Default;
Texture->MipGenSettings = TMGS_NoMipmaps;
Texture->PostEditChange();
Texture->UpdateResource();
// Save the package
FString PackageFilename = FPackageName::LongPackageNameToFilename(FullPath, FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = RF_Public | RF_Standalone;
UPackage::SavePackage(Package, Texture, *PackageFilename, SaveArgs);
FAssetRegistryModule::AssetCreated(Texture);
// Assign to the component
Comp->Thumbnail = Texture;
Comp->MarkPackageDirty();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Thumbnail captured and saved to %s"), *FullPath);
return FReply::Handled();
}
#endif

View File

@@ -0,0 +1,26 @@
#pragma once
#if WITH_EDITOR
#include "CoreMinimal.h"
#include "IDetailCustomization.h"
/**
* Detail panel customization for UPS_Editor_SpawnableComponent.
* Adds a "Capture Thumbnail" button that grabs the Blueprint's auto-generated
* thumbnail and saves it as a persistent UTexture2D asset.
*/
class FPS_Editor_SpawnableComponentCustomization : public IDetailCustomization
{
public:
static TSharedRef<IDetailCustomization> MakeInstance();
virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override;
private:
FReply OnCaptureThumbnailClicked();
TWeakObjectPtr<UObject> EditedComponent;
};
#endif

View File

@@ -0,0 +1,27 @@
#include "PS_Editor_HUD.h"
#include "PS_Editor_MainWidget.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SelectionManager.h"
#include "Blueprint/UserWidget.h"
void APS_Editor_HUD::BeginPlay()
{
Super::BeginPlay();
if (APlayerController* PC = GetOwningPlayerController())
{
MainWidget = CreateWidget<UPS_Editor_MainWidget>(PC, UPS_Editor_MainWidget::StaticClass());
if (MainWidget)
{
// Wire the SelectionManager to the UI
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
MainWidget->SetSelectionManager(EditorPC->GetSelectionManager());
MainWidget->SetSpawnManager(EditorPC->GetSpawnManager());
MainWidget->SetSceneSerializer(EditorPC->GetSceneSerializer());
}
MainWidget->AddToViewport(0);
}
}
}

View File

@@ -0,0 +1,29 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/HUD.h"
#include "PS_Editor_HUD.generated.h"
class UPS_Editor_MainWidget;
/**
* HUD for the PS_Editor runtime editing mode.
* Creates and manages the main editor widget.
*/
UCLASS()
class PS_EDITOR_API APS_Editor_HUD : public AHUD
{
GENERATED_BODY()
public:
/** Returns the main editor widget instance. */
UFUNCTION(BlueprintCallable, Category = "PS Editor|UI")
UPS_Editor_MainWidget* GetMainWidget() const { return MainWidget; }
protected:
virtual void BeginPlay() override;
private:
UPROPERTY()
TObjectPtr<UPS_Editor_MainWidget> MainWidget;
};

View File

@@ -0,0 +1,725 @@
#include "PS_Editor_MainWidget.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SScrollBox.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Images/SImage.h"
#include "Engine/Texture2D.h"
#include "Widgets/Input/SEditableTextBox.h"
#include "Widgets/SOverlay.h"
void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager)
{
SelectionManager = InSelectionManager;
}
void UPS_Editor_MainWidget::SetSpawnManager(UPS_Editor_SpawnManager* InSpawnManager)
{
SpawnManager = InSpawnManager;
}
void UPS_Editor_MainWidget::SetSceneSerializer(UPS_Editor_SceneSerializer* InSerializer)
{
SceneSerializer = InSerializer;
}
TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
{
return SNew(SOverlay)
// Top-left: Title + Toolbar
+ SOverlay::Slot()
.HAlign(HAlign_Left)
.VAlign(VAlign_Top)
.Padding(16.0f)
[
SNew(SVerticalBox)
// Title bar
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
.Padding(FMargin(12.0f, 8.0f))
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("PS Editor")))
.ColorAndOpacity(FLinearColor(0.2f, 0.7f, 1.0f, 1.0f))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 14))
]
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(12.0f, 0.0f, 0.0f, 0.0f)
[
SAssignNew(ModeText, STextBlock)
.Text(FText::FromString(TEXT("Translate (E)")))
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f, 1.0f))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 11))
]
]
]
// Toolbar
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
BuildToolbar()
]
// Scene save/load
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
BuildSceneButtons()
]
]
// Left panel: Spawn Catalogue
+ SOverlay::Slot()
.HAlign(HAlign_Left)
.VAlign(VAlign_Center)
.Padding(16.0f, 80.0f, 0.0f, 60.0f)
[
BuildSpawnCatalog()
]
// Right panel: Properties
+ SOverlay::Slot()
.HAlign(HAlign_Right)
.VAlign(VAlign_Top)
.Padding(16.0f)
[
BuildPropertiesPanel()
]
// Bottom help bar
+ SOverlay::Slot()
.HAlign(HAlign_Center)
.VAlign(VAlign_Bottom)
.Padding(16.0f)
[
SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.75f))
.Padding(FMargin(16.0f, 6.0f))
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("LMB: Select/Pan | RMB: Fly | Alt+LMB/MMB: Orbit | E/R/T: Translate/Rotate/Scale | End: Snap | Del: Delete")))
.ColorAndOpacity(FLinearColor(0.6f, 0.6f, 0.6f, 1.0f))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
]
];
}
TSharedRef<SWidget> UPS_Editor_MainWidget::BuildToolbar()
{
auto MakeButton = [this](const FText& Label, EPS_Editor_TransformMode Mode) -> TSharedRef<SWidget>
{
return SNew(SButton)
.OnClicked_Lambda([this, Mode]() -> FReply
{
OnModeButtonClicked(Mode);
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(Label)
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
.Justification(ETextJustify::Center)
];
};
return SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
.Padding(FMargin(8.0f, 4.0f))
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f)
[ MakeButton(FText::FromString(TEXT("Translate (E)")), EPS_Editor_TransformMode::Translate) ]
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f)
[ MakeButton(FText::FromString(TEXT("Rotate (R)")), EPS_Editor_TransformMode::Rotate) ]
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f)
[ MakeButton(FText::FromString(TEXT("Scale (T)")), EPS_Editor_TransformMode::Scale) ]
+ SHorizontalBox::Slot().AutoWidth().Padding(8.0f, 0.0f, 2.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (UPS_Editor_SelectionManager* SM = SelectionManager.Get())
{
SM->SnapSelectionToGround();
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Snap Ground")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
];
}
TSharedRef<SWidget> UPS_Editor_MainWidget::BuildPropertiesPanel()
{
const FMargin RowPadding(0.0f, 2.0f);
const FSlateFontInfo LabelFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
const FSlateFontInfo ValueFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
const float LabelWidth = 14.0f;
const float FieldWidth = 70.0f;
auto MakeRow = [&](const FText& Label, TSharedPtr<SEditableTextBox>& X, TSharedPtr<SEditableTextBox>& Y, TSharedPtr<SEditableTextBox>& Z, const FLinearColor& ColorX, const FLinearColor& ColorY, const FLinearColor& ColorZ) -> TSharedRef<SWidget>
{
auto MakeField = [&](TSharedPtr<SEditableTextBox>& Field, const FLinearColor& Col) -> TSharedRef<SWidget>
{
return SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(4.0f, 0.0f, 2.0f, 0.0f)
[
SNew(SBorder)
.BorderBackgroundColor(Col)
.Padding(FMargin(3.0f, 1.0f))
[
SNew(STextBlock)
.Font(LabelFont)
.ColorAndOpacity(FLinearColor::White)
]
]
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
[
SAssignNew(Field, SEditableTextBox)
.Font(ValueFont)
.MinDesiredWidth(FieldWidth)
.OnTextCommitted_Lambda([this](const FText&, ETextCommit::Type)
{
ApplyTransformFromUI();
})
];
};
return SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(Label)
.Font(LabelFont)
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
.MinDesiredWidth(LabelWidth)
]
+ SHorizontalBox::Slot().FillWidth(1.0f)
[ MakeField(X, ColorX) ]
+ SHorizontalBox::Slot().FillWidth(1.0f)
[ MakeField(Y, ColorY) ]
+ SHorizontalBox::Slot().FillWidth(1.0f)
[ MakeField(Z, ColorZ) ];
};
const FLinearColor RedX(0.8f, 0.15f, 0.1f);
const FLinearColor GreenY(0.15f, 0.6f, 0.1f);
const FLinearColor BlueZ(0.1f, 0.3f, 0.8f);
return SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
.Padding(FMargin(10.0f, 8.0f))
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight()
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().FillWidth(1.0f)
[
SAssignNew(SelectionInfoText, STextBlock)
.Text(FText::FromString(TEXT("No selection")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 10))
.ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f))
]
]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Location")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[ MakeRow(FText::GetEmpty(), LocX, LocY, LocZ, RedX, GreenY, BlueZ) ]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Rotation")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[ MakeRow(FText::GetEmpty(), RotX, RotY, RotZ, RedX, GreenY, BlueZ) ]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Scale")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[ MakeRow(FText::GetEmpty(), ScaX, ScaY, ScaZ, RedX, GreenY, BlueZ) ]
];
}
TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
{
return SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
.Padding(FMargin(8.0f, 4.0f))
[
SNew(SVerticalBox)
// Save row: text field + save button
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 2.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
.Padding(0.0f, 0.0f, 4.0f, 0.0f)
[
SAssignNew(SaveNameField, SEditableTextBox)
.HintText(FText::FromString(TEXT("Scene name...")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
+ SHorizontalBox::Slot()
.AutoWidth()
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (SaveNameField.IsValid() && SceneSerializer.IsValid() && SpawnManager.IsValid())
{
FString Name = SaveNameField->GetText().ToString();
if (!Name.IsEmpty())
{
SceneSerializer->SaveScene(Name, SpawnManager.Get());
// Refresh scene list
PopulateSceneList();
}
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Save")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
]
// Action buttons: New + Load list
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 2.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(0.0f, 0.0f, 4.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{
SceneSerializer->ClearScene(SpawnManager.Get());
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("New Scene")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
]
// Saved scenes list
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
SAssignNew(SceneListContainer, SVerticalBox)
]
];
}
TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSpawnCatalog()
{
TSharedRef<SVerticalBox> CatalogList = SNew(SVerticalBox);
// Header
CatalogList->AddSlot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 4.0f)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Spawn Catalogue")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
.ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f))
];
// Note: entries will be populated in NativeConstruct when SpawnManager is set.
// For now, show a placeholder. The actual list is built dynamically.
CatalogList->AddSlot()
.AutoHeight()
[
SAssignNew(SpawnListContainer, SVerticalBox)
];
return SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
.Padding(FMargin(10.0f, 8.0f))
[
SNew(SBox)
.MinDesiredWidth(180.0f)
.MaxDesiredHeight(400.0f)
[
SNew(SScrollBox)
+ SScrollBox::Slot()
[
CatalogList
]
]
];
}
void UPS_Editor_MainWidget::NativeConstruct()
{
Super::NativeConstruct();
PopulateSpawnCatalog();
PopulateSceneList();
}
void UPS_Editor_MainWidget::PopulateSceneList()
{
if (!SceneListContainer.IsValid()) return;
SceneListContainer->ClearChildren();
UPS_Editor_SceneSerializer* Ser = SceneSerializer.Get();
if (!Ser) return;
TArray<FString> Scenes = Ser->GetSavedSceneNames();
if (Scenes.Num() == 0)
{
SceneListContainer->AddSlot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("No saved scenes")))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
];
return;
}
SceneListContainer->AddSlot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Load scene:")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.ColorAndOpacity(FLinearColor(0.6f, 0.6f, 0.6f))
];
for (const FString& SceneName : Scenes)
{
FString Name = SceneName;
SceneListContainer->AddSlot()
.AutoHeight()
.Padding(0.0f, 1.0f)
[
SNew(SButton)
.OnClicked_Lambda([this, Name]() -> FReply
{
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
{
SceneSerializer->LoadScene(Name, SpawnManager.Get());
if (SaveNameField.IsValid())
{
SaveNameField->SetText(FText::FromString(Name));
}
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(SceneName))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
];
}
}
void UPS_Editor_MainWidget::PopulateSpawnCatalog()
{
if (!SpawnListContainer.IsValid())
{
return;
}
SpawnListContainer->ClearChildren();
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
if (!SM)
{
SpawnListContainer->AddSlot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("No catalog loaded")))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
];
return;
}
const TArray<FPS_Editor_ResolvedEntry>& Entries = SM->GetResolvedEntries();
if (Entries.Num() == 0)
{
SpawnListContainer->AddSlot()
.AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Catalog is empty")))
.Font(FCoreStyle::GetDefaultFontStyle("Italic", 9))
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
];
return;
}
// Group by category
FString CurrentCategory;
for (int32 i = 0; i < Entries.Num(); ++i)
{
const FPS_Editor_ResolvedEntry& Entry = Entries[i];
// Category header
if (Entry.Category != CurrentCategory)
{
CurrentCategory = Entry.Category;
SpawnListContainer->AddSlot()
.AutoHeight()
.Padding(0.0f, 6.0f, 0.0f, 2.0f)
[
SNew(STextBlock)
.Text(FText::FromString(CurrentCategory))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 9))
.ColorAndOpacity(FLinearColor(0.6f, 0.6f, 0.6f))
];
}
// Spawn card: thumbnail on top, name below
const int32 EntryIndex = i;
const float CardSize = 80.0f;
TSharedRef<SVerticalBox> CardContent = SNew(SVerticalBox);
// Thumbnail
if (Entry.Thumbnail)
{
TSharedPtr<FSlateBrush> Brush = MakeShared<FSlateBrush>();
Brush->SetResourceObject(Entry.Thumbnail);
Brush->ImageSize = FVector2D(CardSize, CardSize);
Brush->DrawAs = ESlateBrushDrawType::Image;
Brush->Tiling = ESlateBrushTileType::NoTile;
CachedBrushes.Add(Brush);
TWeakPtr<FSlateBrush> WeakBrush = Brush;
CardContent->AddSlot()
.AutoHeight()
.HAlign(HAlign_Center)
[
SNew(SImage)
.Image_Lambda([WeakBrush]() -> const FSlateBrush*
{
return WeakBrush.IsValid() ? WeakBrush.Pin().Get() : nullptr;
})
.DesiredSizeOverride(FVector2D(CardSize, CardSize))
];
}
// Name below thumbnail
CardContent->AddSlot()
.AutoHeight()
.HAlign(HAlign_Center)
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
SNew(STextBlock)
.Text(FText::FromString(Entry.DisplayName))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.Justification(ETextJustify::Center)
.ColorAndOpacity(FLinearColor::White)
];
SpawnListContainer->AddSlot()
.AutoHeight()
.Padding(4.0f, 4.0f)
[
SNew(SButton)
.OnClicked_Lambda([this, EntryIndex]() -> FReply
{
if (UPS_Editor_SpawnManager* Mgr = SpawnManager.Get())
{
Mgr->SpawnFromCatalog(EntryIndex);
}
return FReply::Handled();
})
[
SNew(SBox)
.MinDesiredWidth(CardSize + 8.0f)
[
CardContent
]
]
];
}
}
void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
RefreshPropertiesFromSelection();
}
void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
{
bIsRefreshing = true;
UPS_Editor_SelectionManager* SM = SelectionManager.Get();
if (!SM)
{
bIsRefreshing = false;
return;
}
// Update mode text
if (ModeText.IsValid())
{
FString ModeStr;
switch (SM->GetTransformMode())
{
case EPS_Editor_TransformMode::Translate: ModeStr = TEXT("Translate (E)"); break;
case EPS_Editor_TransformMode::Rotate: ModeStr = TEXT("Rotate (R)"); break;
case EPS_Editor_TransformMode::Scale: ModeStr = TEXT("Scale (T)"); break;
}
ModeText->SetText(FText::FromString(ModeStr));
}
if (!SM->IsAnythingSelected())
{
if (SelectionInfoText.IsValid())
{
SelectionInfoText->SetText(FText::FromString(TEXT("No selection")));
}
auto ClearField = [](TSharedPtr<SEditableTextBox>& Field)
{
if (Field.IsValid()) Field->SetText(FText::FromString(TEXT("--")));
};
ClearField(LocX); ClearField(LocY); ClearField(LocZ);
ClearField(RotX); ClearField(RotY); ClearField(RotZ);
ClearField(ScaX); ClearField(ScaY); ClearField(ScaZ);
bIsRefreshing = false;
return;
}
// Show first selected actor info
const TArray<TWeakObjectPtr<AActor>>& Selected = SM->GetSelectedActors();
AActor* Actor = Selected[0].Get();
if (!Actor)
{
bIsRefreshing = false;
return;
}
if (SelectionInfoText.IsValid())
{
FString Info = FString::Printf(TEXT("%s (%d)"), *Actor->GetActorLabel(), Selected.Num());
SelectionInfoText->SetText(FText::FromString(Info));
}
const FVector Loc = Actor->GetActorLocation();
const FRotator Rot = Actor->GetActorRotation();
const FVector Sca = Actor->GetActorScale3D();
auto SetField = [this](TSharedPtr<SEditableTextBox>& Field, float Val)
{
if (Field.IsValid() && !Field->HasKeyboardFocus())
{
Field->SetText(FText::FromString(FloatToString(Val)));
}
};
SetField(LocX, Loc.X); SetField(LocY, Loc.Y); SetField(LocZ, Loc.Z);
SetField(RotX, Rot.Roll); SetField(RotY, Rot.Pitch); SetField(RotZ, Rot.Yaw);
SetField(ScaX, Sca.X); SetField(ScaY, Sca.Y); SetField(ScaZ, Sca.Z);
bIsRefreshing = false;
}
void UPS_Editor_MainWidget::ApplyTransformFromUI()
{
if (bIsRefreshing) return;
UPS_Editor_SelectionManager* SM = SelectionManager.Get();
if (!SM || !SM->IsAnythingSelected()) return;
AActor* Actor = SM->GetSelectedActors()[0].Get();
if (!Actor) return;
auto GetFloat = [](TSharedPtr<SEditableTextBox>& Field, float Default) -> float
{
if (!Field.IsValid()) return Default;
return FCString::Atof(*Field->GetText().ToString());
};
// Capture old transform for undo
const FTransform OldTransform = Actor->GetActorTransform();
const FVector NewLoc(GetFloat(LocX, 0), GetFloat(LocY, 0), GetFloat(LocZ, 0));
const FRotator NewRot(GetFloat(RotY, 0), GetFloat(RotZ, 0), GetFloat(RotX, 0));
const FVector NewScale(GetFloat(ScaX, 1), GetFloat(ScaY, 1), GetFloat(ScaZ, 1));
Actor->SetActorLocation(NewLoc);
Actor->SetActorRotation(NewRot);
Actor->SetActorScale3D(NewScale);
// Record undo action
if (APlayerController* PC = GetOwningPlayer())
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
TSharedPtr<FPS_Editor_TransformAction> Action = MakeShared<FPS_Editor_TransformAction>();
Action->Actors.Add(Actor);
Action->OldTransforms.Add(OldTransform);
Action->NewTransforms.Add(Actor->GetActorTransform());
Action->Description = TEXT("UI Transform Edit");
UM->RecordAction(Action);
}
}
}
}
FString UPS_Editor_MainWidget::FloatToString(float Value) const
{
return FString::Printf(TEXT("%.2f"), Value);
}
void UPS_Editor_MainWidget::OnModeButtonClicked(EPS_Editor_TransformMode Mode)
{
if (UPS_Editor_SelectionManager* SM = SelectionManager.Get())
{
SM->SetTransformMode(Mode);
}
}

View File

@@ -0,0 +1,75 @@
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "PS_Editor_SelectionTypes.h"
#include "PS_Editor_MainWidget.generated.h"
class UPS_Editor_SelectionManager;
class UPS_Editor_SpawnManager;
class UPS_Editor_SceneSerializer;
/**
* Main editor widget for PS_Editor.
* Contains: title bar, transform mode toolbar, properties panel, help bar.
* Built entirely in C++ using Slate.
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_MainWidget : public UUserWidget
{
GENERATED_BODY()
public:
/** Set manager references. Must be called after construction. */
void SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager);
void SetSpawnManager(UPS_Editor_SpawnManager* InSpawnManager);
void SetSceneSerializer(UPS_Editor_SceneSerializer* InSerializer);
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
virtual void NativeConstruct() override;
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
private:
UPROPERTY()
TWeakObjectPtr<UPS_Editor_SelectionManager> SelectionManager;
UPROPERTY()
TWeakObjectPtr<UPS_Editor_SpawnManager> SpawnManager;
UPROPERTY()
TWeakObjectPtr<UPS_Editor_SceneSerializer> SceneSerializer;
// ---- Slate widget refs for dynamic updates ----
TSharedPtr<STextBlock> ModeText;
TSharedPtr<STextBlock> SelectionInfoText;
// Spawn catalog
TSharedPtr<SVerticalBox> SpawnListContainer;
// Scene save/load
TSharedPtr<SEditableTextBox> SaveNameField;
TSharedPtr<SVerticalBox> SceneListContainer;
TArray<TSharedPtr<FSlateBrush>> CachedBrushes;
// Transform fields
TSharedPtr<SEditableTextBox> LocX, LocY, LocZ;
TSharedPtr<SEditableTextBox> RotX, RotY, RotZ;
TSharedPtr<SEditableTextBox> ScaX, ScaY, ScaZ;
// ---- Helpers ----
TSharedRef<SWidget> BuildToolbar();
TSharedRef<SWidget> BuildPropertiesPanel();
TSharedRef<SWidget> BuildSpawnCatalog();
TSharedRef<SWidget> BuildSceneButtons();
void PopulateSceneList();
void PopulateSpawnCatalog();
void RefreshPropertiesFromSelection();
void ApplyTransformFromUI();
FString FloatToString(float Value) const;
void OnModeButtonClicked(EPS_Editor_TransformMode Mode);
/** Whether we are currently refreshing UI from selection (avoid feedback loop). */
bool bIsRefreshing = false;
};