Compare commits
17 Commits
93b9c7d927
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eca4fc94b | |||
| 4a98c6402b | |||
| f6efc00c73 | |||
| 34280de44a | |||
| ac91e4e8f9 | |||
| c7ec3e9653 | |||
| d9ed016297 | |||
| 67ac3ffab7 | |||
| 8f7476d2db | |||
| b9ec67f9ff | |||
| 2a53d12e68 | |||
| 49cb480e15 | |||
| 7f2600f0b8 | |||
| c520f3ccd9 | |||
| 2f8633d23b | |||
| 98de4dff6f | |||
| b51f063d52 |
@@ -1,4 +1,4 @@
|
||||
- [PS_Editor plugin project](project_ps_editor.md) — Runtime editor plugin for UE 5.5, Phases 1-4 done, outline material pending
|
||||
- [PS_Editor plugin project](project_ps_editor.md) — Runtime editor plugin for UE 5.7 (migrated from 5.5 in April 2026), 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)
|
||||
- [UE5 build process](reference_build.md) — CLI build command for PROSERVE_UE_5_7 (5.5 host deprecated), Build.bat path, target name, junction check
|
||||
- [Build workflow](feedback_build_workflow.md) — Prefer Live Coding (Ctrl+Alt+F11) when UE is open, CLI build only when UE is closed
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
name: PS_Editor plugin project
|
||||
description: Runtime editor plugin for UE 5.5 - object placement, scene editing, JSON save/load in packaged builds
|
||||
description: Runtime editor plugin for UE 5.7 - 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.
|
||||
PS_Editor is a runtime editor plugin for Unreal Engine 5.7 for the PROSERVE project. The plugin was started on UE 5.5; the host project migrated to UE 5.7 in April 2026 and the plugin follows. Build details in reference_build.md.
|
||||
|
||||
Key decisions:
|
||||
- UI: UMG driven by C++ (Slate)
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
---
|
||||
name: UE5 build process
|
||||
description: How to build the PS_ProserveEditor UE 5.5 project from command line on Windows
|
||||
description: How to build the PROSERVE host project (currently UE 5.7) 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`).
|
||||
The PS_Editor plugin is developed in `E:\ASTERION\GIT\PS_Editor\Unreal\Plugins\PS_Editor` and consumed by the PROSERVE host project via an NTFS junction. Always build the host project, not the plugin in isolation.
|
||||
|
||||
**Current host project (April 2026):** `E:\ASTERION\SVN\DEV\PROSERVE_UE_5_7\PROSERVE_UE_5_7.uproject` (Unreal Engine 5.7)
|
||||
|
||||
The previous 5.5 host (`PROSERVE_UE_5_5`) is deprecated — do not use unless explicitly asked.
|
||||
|
||||
UE 5.7 is installed at `C:\Program Files\Epic Games\UE_5.7` (found via registry key `HKLM\SOFTWARE\EpicGames\Unreal Engine\5.7`).
|
||||
|
||||
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
|
||||
"C:/Program Files/Epic Games/UE_5.7/Engine/Build/BatchFiles/Build.bat" PROSERVE_UE_5_7Editor Win64 Development -Project="E:/ASTERION/SVN/DEV/PROSERVE_UE_5_7/PROSERVE_UE_5_7.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.
|
||||
**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 `PROSERVE_UE_5_7Editor` (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
|
||||
|
||||
**Plugin junction verification:**
|
||||
```
|
||||
ls -la E:/ASTERION/SVN/DEV/PROSERVE_UE_5_7/Plugins/ | grep PS_Editor
|
||||
```
|
||||
Should show a symlink pointing to `/e/ASTERION/GIT/PS_Editor/Unreal/Plugins/PS_Editor`. If broken, the host project will build against a stale or missing plugin copy.
|
||||
|
||||
48
CLAUDE.md
48
CLAUDE.md
@@ -1,7 +1,7 @@
|
||||
# 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.
|
||||
Unreal Engine 5.7 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. The plugin was started on UE 5.5; the host project migrated to UE 5.7 in April 2026.
|
||||
|
||||
## Architecture
|
||||
- **Plugin**: PS_Editor (runtime plugin, must work in packaged builds)
|
||||
@@ -57,6 +57,29 @@ if (UPS_Editor_SceneLoader::IsInEditorMode())
|
||||
// editor-only behaviour
|
||||
}
|
||||
```
|
||||
|
||||
### Known PIE-only AI quirk — use Standalone for AI testing
|
||||
|
||||
When the editor's "Play" button calls `UGameplayStatics::OpenLevel(BaseLevel)` from a PIE
|
||||
session, the BaseLevel's `RecastNavMesh` actor ends up pending-kill at registration time
|
||||
(UE logs `RegistrationFailed_DataPendingKill` ×2 and silently spawns an empty
|
||||
`AbstractNavData-Default` as fallback). All `FindPathAsync` / `ProjectPointToNavigation`
|
||||
queries return empty after that — pathfinding looks broken with no error.
|
||||
|
||||
This is **PIE-specific**:
|
||||
- Standalone Game (Window → Standalone) — works correctly
|
||||
- Packaged builds — work correctly
|
||||
- PIE direct on BaseLevel (no editor map / OpenLevel involved) — works correctly
|
||||
- PIE on editor map → click Play (OpenLevel transition inside PIE) — broken
|
||||
|
||||
Multiple workaround attempts have all failed (cascading re-registrations through
|
||||
`RegisterNavData` / `OnInitializeActors` / `AddNavigationSystemToWorld` / `Build` /
|
||||
`SpawnMissingNavigationData`; transition map; duplicated sublevel suffixed `_default`).
|
||||
The cause is internal to PIE's OpenLevel handling and cannot be fixed from the plugin
|
||||
side without engine modifications.
|
||||
|
||||
**Workflow:** for AI iteration, run **Standalone Game** instead of PIE. It exercises the
|
||||
exact same code path as packaged builds.
|
||||
or gate on `bIsCurrentlyLoading` to skip BP init during JSON restore.
|
||||
|
||||
## UI Modes — IMPORTANT for widget changes
|
||||
@@ -83,15 +106,28 @@ Symptom that the wrong widget was edited: logs you added don't appear in the Out
|
||||
- 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.
|
||||
|
||||
## Workflow — CRITICAL
|
||||
- **Do not make behavior / logic changes without first explaining the plan and waiting for the
|
||||
user's go-ahead.** Explain what the change will be, why it fixes the issue, and what side
|
||||
effects it could have. Only after the user approves (or asks to proceed), apply the edit.
|
||||
- Diagnostic-only additions (temporary `UE_LOG`, read-only inspections) are fine without
|
||||
approval because they can't regress the project.
|
||||
- Refactors, algorithm changes, comportement changes, new defaults, removed features — all
|
||||
require approval first, even when they seem obvious.
|
||||
- When revealing new data (e.g. logs) suggests a fix, STATE the proposed fix in plain words
|
||||
and wait. Don't pre-emptively write + apply the code change in the same turn.
|
||||
|
||||
## Build
|
||||
- Engine: Unreal Engine 5.5
|
||||
- Plugin location: `Unreal/Plugins/PS_Editor/`
|
||||
- Game module: `PS_ProserveEditor` (in `Unreal/Source/PS_ProserveEditor/`)
|
||||
- Engine: Unreal Engine 5.7 (host project migrated from 5.5 in April 2026)
|
||||
- Plugin location: `Unreal/Plugins/PS_Editor/` (consumed via NTFS junction from the PROSERVE host project's `Plugins/` directory)
|
||||
- Host project: `E:\ASTERION\SVN\DEV\PROSERVE_UE_5_7\PROSERVE_UE_5_7.uproject` — always build this, not the plugin in isolation
|
||||
- Game module: `PROSERVE_UE_5_7` (in the host project's `Source/`)
|
||||
- CLI build command (Editor, Development):
|
||||
```
|
||||
"<UE_INSTALL>/Engine/Build/BatchFiles/Build.bat" PS_ProserveEditorEditor Win64 Development -Project="<REPO>/Unreal/PS_ProserveEditor.uproject" -WaitMutex -FromMsBuild
|
||||
"C:/Program Files/Epic Games/UE_5.7/Engine/Build/BatchFiles/Build.bat" PROSERVE_UE_5_7Editor Win64 Development -Project="E:/ASTERION/SVN/DEV/PROSERVE_UE_5_7/PROSERVE_UE_5_7.uproject" -WaitMutex -FromMsBuild
|
||||
```
|
||||
UE install path can be found via registry: `HKLM\SOFTWARE\EpicGames\Unreal Engine\5.5` > `InstalledDirectory`
|
||||
UE install path can be found via registry: `HKLM\SOFTWARE\EpicGames\Unreal Engine\5.7` > `InstalledDirectory`
|
||||
- The 5.5 host (`PROSERVE_UE_5_5`) is deprecated — do not build against it unless explicitly asked.
|
||||
|
||||
## 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:
|
||||
|
||||
Binary file not shown.
@@ -20,11 +20,11 @@ public:
|
||||
#if WITH_EDITORONLY_DATA
|
||||
protected:
|
||||
/** Icon visible in the editor viewport. Override the sprite in a derived Blueprint if needed. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS_Editor")
|
||||
TObjectPtr<class UBillboardComponent> SpriteComponent;
|
||||
|
||||
/** Arrow showing the view direction in the editor viewport. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS_Editor")
|
||||
TObjectPtr<class UArrowComponent> ArrowComponent;
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
#include "PS_Editor_GameInstanceSubsystem.h"
|
||||
#include "PS_Editor_SceneLoader.h"
|
||||
#include "PS_Editor_SceneData.h"
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "JsonObjectConverter.h"
|
||||
|
||||
void UPS_Editor_GameInstanceSubsystem::SetPendingScenario(const FString& ScenarioName, const FString& BaseLevel)
|
||||
void UPS_Editor_GameInstanceSubsystem::SetPendingScenario(const FString& ScenarioName, const FString& BaseLevel, uint8 ScenarioType)
|
||||
{
|
||||
PendingScenarioName = ScenarioName;
|
||||
PendingBaseLevel = BaseLevel;
|
||||
PendingScenarioType = ScenarioType;
|
||||
LastEditedScenarioName = ScenarioName;
|
||||
LastEditedBaseLevel = BaseLevel;
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Pending scenario set: '%s' (base level: '%s')"), *ScenarioName, *BaseLevel);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Pending scenario set: '%s' (base level: '%s', type: %u)"),
|
||||
*ScenarioName, *BaseLevel, ScenarioType);
|
||||
}
|
||||
|
||||
FString UPS_Editor_GameInstanceSubsystem::ConsumePendingScenario()
|
||||
@@ -45,3 +51,44 @@ void UPS_Editor_GameInstanceSubsystem::ReturnToEditor(const UObject* WorldContex
|
||||
|
||||
OpenEditor(WorldContextObject);
|
||||
}
|
||||
|
||||
bool UPS_Editor_GameInstanceSubsystem::ReadScenarioMetadata(const FString& ScenarioName, uint8& OutScenarioType, FString& OutBaseLevel) const
|
||||
{
|
||||
OutScenarioType = 0;
|
||||
OutBaseLevel.Empty();
|
||||
|
||||
if (ScenarioName.IsEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const FString FilePath = UPS_Editor_SceneLoader::GetSceneFilePath(ScenarioName);
|
||||
FString JsonString;
|
||||
if (!FFileHelper::LoadFileToString(JsonString, *FilePath))
|
||||
{
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: ReadScenarioMetadata — file not found: %s"), *FilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the full SceneData. We could parse just the metadata fields with a custom
|
||||
// reader, but FJsonObjectConverter is fast enough for the scenario sizes we deal with
|
||||
// (tens of actors max), and reusing the same struct keeps version handling consistent.
|
||||
FPS_Editor_SceneData SceneData;
|
||||
if (!FJsonObjectConverter::JsonObjectStringToUStruct(JsonString, &SceneData, 0, 0))
|
||||
{
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: ReadScenarioMetadata — failed to parse JSON for '%s'"), *ScenarioName);
|
||||
return false;
|
||||
}
|
||||
|
||||
OutScenarioType = SceneData.ScenarioType;
|
||||
OutBaseLevel = SceneData.LevelName;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8 UPS_Editor_GameInstanceSubsystem::GetScenarioTypeFromFile(const FString& ScenarioName) const
|
||||
{
|
||||
uint8 Type = 0;
|
||||
FString DummyLevel;
|
||||
ReadScenarioMetadata(ScenarioName, Type, DummyLevel);
|
||||
return Type;
|
||||
}
|
||||
|
||||
@@ -18,41 +18,68 @@ class PS_EDITOR_API UPS_Editor_GameInstanceSubsystem : public UGameInstanceSubsy
|
||||
|
||||
public:
|
||||
/** Scenario name to load on the next level. Set by PROSERVE or Play button, consumed on load. */
|
||||
UPROPERTY(BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(BlueprintReadWrite, Category = "PS_Editor")
|
||||
FString PendingScenarioName;
|
||||
|
||||
/** Base level name associated with the pending scenario. */
|
||||
UPROPERTY(BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(BlueprintReadWrite, Category = "PS_Editor")
|
||||
FString PendingBaseLevel;
|
||||
|
||||
/** Last scenario/base level edited. Persists after consume — used by ReturnToEditor. */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(BlueprintReadOnly, Category = "PS_Editor")
|
||||
FString LastEditedScenarioName;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(BlueprintReadOnly, Category = "PS_Editor")
|
||||
FString LastEditedBaseLevel;
|
||||
|
||||
/** Name of the editor map to return to. Set this once at startup. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS_Editor")
|
||||
FString EditorMapName;
|
||||
|
||||
/** True when the Play button was pressed (gameplay mode). False when just loading/editing a scenario. */
|
||||
UPROPERTY(BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(BlueprintReadWrite, Category = "PS_Editor")
|
||||
bool bIsPlayMode = false;
|
||||
|
||||
/** Set pending scenario + base level (for opening the editor or gameplay). */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
void SetPendingScenario(const FString& ScenarioName, const FString& BaseLevel);
|
||||
/** Pending scenario type as uint8 — index into UPS_Editor_MasterCatalog::ScenarioTypeEnum.
|
||||
* Set by the Play button alongside PendingScenarioName. The gameplay GameMode reads it
|
||||
* (Byte to Enum) and routes its scenario-type-specific logic. Default 0 = first enum entry. */
|
||||
UPROPERTY(BlueprintReadWrite, Category = "PS_Editor")
|
||||
uint8 PendingScenarioType = 0;
|
||||
|
||||
/** Set pending scenario + base level + type (for opening the editor or gameplay). */
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void SetPendingScenario(const FString& ScenarioName, const FString& BaseLevel, uint8 ScenarioType = 0);
|
||||
|
||||
/**
|
||||
* Pre-parse a scenario file from disk (without spawning anything) to extract its
|
||||
* persisted metadata: scenario type and base level. Use this from PROSERVE GameMode
|
||||
* BeginPlay (or earlier) to know what type a scenario is BEFORE deciding to load it,
|
||||
* when the standard Play-button flow hasn't populated PendingScenarioType.
|
||||
*
|
||||
* @param ScenarioName Name of the scenario file (without extension).
|
||||
* @param OutScenarioType Set to the scenario's saved type (uint8 index into MasterCatalog::ScenarioTypeEnum). 0 on failure.
|
||||
* @param OutBaseLevel Set to the scenario's saved base level name. Empty on failure.
|
||||
* @return True if the file was found and parsed successfully.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
bool ReadScenarioMetadata(const FString& ScenarioName, uint8& OutScenarioType, FString& OutBaseLevel) const;
|
||||
|
||||
/**
|
||||
* Convenience: read just the scenario type from a file. Returns 0 if the file isn't
|
||||
* found / can't be parsed (which is also the default for a saved-but-untyped scenario).
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS_Editor")
|
||||
uint8 GetScenarioTypeFromFile(const FString& ScenarioName) const;
|
||||
|
||||
/** Consume the pending scenario (reads and clears). */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
FString ConsumePendingScenario();
|
||||
|
||||
/** Open the editor with the current pending scenario + base level. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor", meta = (WorldContext = "WorldContextObject"))
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor", meta = (WorldContext = "WorldContextObject"))
|
||||
void OpenEditor(const UObject* WorldContextObject);
|
||||
|
||||
/** Return to the editor, keeping the current base level and scenario. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor", meta = (WorldContext = "WorldContextObject"))
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor", meta = (WorldContext = "WorldContextObject"))
|
||||
void ReturnToEditor(const UObject* WorldContextObject);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,6 @@ public:
|
||||
APS_Editor_GameMode();
|
||||
|
||||
/** Whether the editor is currently in edit mode (vs play/preview mode). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS_Editor")
|
||||
bool bIsEditMode = true;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "PS_Editor_SelectionManager.h"
|
||||
#include "PS_Editor_GroundSnap.h"
|
||||
#include "PS_Editor_NavUtils.h"
|
||||
#include "PS_Editor_SpawnManager.h"
|
||||
#include "PS_Editor_PlayerController.h"
|
||||
#include "PS_Editor_Gizmo.h"
|
||||
#include "PS_Editor_UndoManager.h"
|
||||
@@ -77,6 +79,26 @@ void APS_Editor_Pawn::Tick(float DeltaTime)
|
||||
{
|
||||
Super::Tick(DeltaTime);
|
||||
|
||||
// Focus animation: smoothly interpolate camera location/rotation toward the framed
|
||||
// selection. Uses ease-in-out (smoothstep) so the camera doesn't snap at the boundaries.
|
||||
if (bIsFocusing)
|
||||
{
|
||||
FocusElapsed = FMath::Min(FocusElapsed + DeltaTime, FocusDuration);
|
||||
const float Linear = FocusElapsed / FocusDuration;
|
||||
const float Alpha = Linear * Linear * (3.0f - 2.0f * Linear); // smoothstep
|
||||
const FVector NewLoc = FMath::Lerp(FocusStartLocation, FocusTargetLocation, Alpha);
|
||||
const FRotator NewRot = FMath::Lerp(FocusStartRotation, FocusTargetRotation, Alpha);
|
||||
SetActorLocation(NewLoc);
|
||||
SetActorRotation(NewRot);
|
||||
if (FocusElapsed >= FocusDuration)
|
||||
{
|
||||
bIsFocusing = false;
|
||||
// Sync orbit state so a subsequent Alt+drag pivots around the new focal point.
|
||||
OrbitYaw = NewRot.Yaw;
|
||||
OrbitPitch = NewRot.Pitch;
|
||||
}
|
||||
}
|
||||
|
||||
if (APlayerController* PC = Cast<APlayerController>(GetController()))
|
||||
{
|
||||
bAltHeld = PC->IsInputKeyDown(EKeys::LeftAlt) || PC->IsInputKeyDown(EKeys::RightAlt);
|
||||
@@ -229,6 +251,16 @@ void APS_Editor_Pawn::CreateInputActions()
|
||||
IA_Cancel = NewObject<UInputAction>(this, TEXT("IA_Cancel"));
|
||||
IA_Cancel->ValueType = EInputActionValueType::Boolean;
|
||||
EditorMappingContext->MapKey(IA_Cancel, EKeys::Escape);
|
||||
|
||||
// Focus selection (Maya/Blender style)
|
||||
IA_Focus = NewObject<UInputAction>(this, TEXT("IA_Focus"));
|
||||
IA_Focus->ValueType = EInputActionValueType::Boolean;
|
||||
EditorMappingContext->MapKey(IA_Focus, EKeys::F);
|
||||
|
||||
// Toggle navmesh visualization (mimics UE editor's P key)
|
||||
IA_ToggleNavMesh = NewObject<UInputAction>(this, TEXT("IA_ToggleNavMesh"));
|
||||
IA_ToggleNavMesh->ValueType = EInputActionValueType::Boolean;
|
||||
EditorMappingContext->MapKey(IA_ToggleNavMesh, EKeys::P);
|
||||
}
|
||||
|
||||
void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
|
||||
@@ -275,6 +307,8 @@ void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComp
|
||||
EIC->BindAction(IA_SplineMode, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleSplineMode);
|
||||
EIC->BindAction(IA_Confirm, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleConfirm);
|
||||
EIC->BindAction(IA_Cancel, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleCancel);
|
||||
EIC->BindAction(IA_Focus, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleFocus);
|
||||
EIC->BindAction(IA_ToggleNavMesh, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleToggleNavMesh);
|
||||
}
|
||||
|
||||
// ---- Input Handlers ----
|
||||
@@ -448,15 +482,31 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
|
||||
const FQuat RotationQuat(AxisDir, FMath::DegreesToRadians(SnapAngle));
|
||||
const FVector GizmoLoc = GizmoDragPlaneOrigin;
|
||||
|
||||
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
|
||||
// ChildMovable rotation: rotate only the active child instead of all selected actors.
|
||||
// We compose the delta quat onto the initial rotation captured at drag start —
|
||||
// reading the live value each frame would feed back through GetChildRotation and
|
||||
// drift if the BP impl clamps axes.
|
||||
if (ActiveSplinePointIndex >= 0
|
||||
&& DraggedSplineActor.IsValid()
|
||||
&& DraggedSplineActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass())
|
||||
&& SplinePointDragInitialRotations.IsValidIndex(ActiveSplinePointIndex))
|
||||
{
|
||||
if (AActor* Actor = Selected[i].Get())
|
||||
const FQuat OrigRot = SplinePointDragInitialRotations[ActiveSplinePointIndex].Quaternion();
|
||||
const FRotator NewRot = (RotationQuat * OrigRot).Rotator();
|
||||
IPS_Editor_ChildMovable::Execute_RotateChild(DraggedSplineActor.Get(), ActiveSplinePointIndex, NewRot);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
|
||||
{
|
||||
const FVector Offset = GizmoDragInitialTransforms[i].GetLocation() - GizmoLoc;
|
||||
Actor->SetActorLocation(GizmoLoc + RotationQuat.RotateVector(Offset));
|
||||
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());
|
||||
const FQuat OrigRot = GizmoDragInitialTransforms[i].GetRotation();
|
||||
Actor->SetActorRotation((RotationQuat * OrigRot).Rotator());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,6 +730,9 @@ void APS_Editor_Pawn::HandleLeftClickStarted(const FInputActionValue& Value)
|
||||
{
|
||||
ActiveSplinePointIndex = ChildIdx;
|
||||
DraggedSplineActor = ActiveActor;
|
||||
// Clear any previous highlight before applying the new one — without this, picking
|
||||
// a different child leaves the prior one stuck in highlight state.
|
||||
IPS_Editor_ChildMovable::Execute_ClearChildHighlight(ActiveActor);
|
||||
IPS_Editor_ChildMovable::Execute_HighlightChild(ActiveActor, ChildIdx);
|
||||
|
||||
if (UPS_Editor_SelectionManager* SM = EdPC_CM->GetSelectionManager())
|
||||
@@ -759,7 +812,7 @@ void APS_Editor_Pawn::HandleLeftClickStarted(const FInputActionValue& Value)
|
||||
InsertSM->SetTransformMode(EPS_Editor_TransformMode::Translate);
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Inserted spline point at index %d"), InsertIdx);
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Inserted spline point at index %d"), InsertIdx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -815,7 +868,7 @@ void APS_Editor_Pawn::HandleLeftClickStarted(const FInputActionValue& Value)
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Added spline point at end (index %d)"), NewIdx);
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Added spline point at end (index %d)"), NewIdx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -849,6 +902,7 @@ void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value)
|
||||
if (ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid())
|
||||
{
|
||||
TArray<FVector> NewPoints;
|
||||
TArray<FRotator> NewRotations;
|
||||
FString Desc;
|
||||
|
||||
if (IPS_Editor_SplineEditable* SplineActor = Cast<IPS_Editor_SplineEditable>(DraggedSplineActor.Get()))
|
||||
@@ -859,7 +913,16 @@ void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value)
|
||||
else if (DraggedSplineActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass()))
|
||||
{
|
||||
NewPoints = IPS_Editor_ChildMovable::Execute_GetAllChildLocations(DraggedSplineActor.Get());
|
||||
Desc = TEXT("Move child element");
|
||||
// Snapshot rotations so the undo system restores both translation and rotation.
|
||||
// The arrays may be empty if the BP didn't implement rotation — that's fine,
|
||||
// the undo Execute/Undo only applies when both old and new are non-empty.
|
||||
NewRotations = IPS_Editor_ChildMovable::Execute_GetAllChildRotations(DraggedSplineActor.Get());
|
||||
bool bIsRotate = false;
|
||||
if (UPS_Editor_SelectionManager* SMDesc = EditorPC->GetSelectionManager())
|
||||
{
|
||||
bIsRotate = (SMDesc->GetTransformMode() == EPS_Editor_TransformMode::Rotate);
|
||||
}
|
||||
Desc = bIsRotate ? TEXT("Rotate child element") : TEXT("Move child element");
|
||||
}
|
||||
|
||||
if (NewPoints.Num() > 0)
|
||||
@@ -868,6 +931,8 @@ void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value)
|
||||
Action->SplineActor = DraggedSplineActor.Get();
|
||||
Action->OldPoints = SplinePointDragInitialState;
|
||||
Action->NewPoints = NewPoints;
|
||||
Action->OldRotations = SplinePointDragInitialRotations;
|
||||
Action->NewRotations = NewRotations;
|
||||
Action->Description = Desc;
|
||||
UM->RecordAction(Action);
|
||||
}
|
||||
@@ -994,7 +1059,7 @@ void APS_Editor_Pawn::HandleTranslateMode(const FInputActionValue& Value)
|
||||
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
|
||||
{
|
||||
SM->SetTransformMode(EPS_Editor_TransformMode::Translate);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Translate mode"));
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Translate mode"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1007,7 +1072,7 @@ void APS_Editor_Pawn::HandleRotateMode(const FInputActionValue& Value)
|
||||
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
|
||||
{
|
||||
SM->SetTransformMode(EPS_Editor_TransformMode::Rotate);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Rotate mode"));
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Rotate mode"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1020,7 +1085,7 @@ void APS_Editor_Pawn::HandleScaleMode(const FInputActionValue& Value)
|
||||
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
|
||||
{
|
||||
SM->SetTransformMode(EPS_Editor_TransformMode::Scale);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scale mode"));
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Scale mode"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1090,7 +1155,7 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Deleted spline point, %d remaining"), Spline->GetNumPoints());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Deleted spline point, %d remaining"), Spline->GetNumPoints());
|
||||
}
|
||||
else if (Spline)
|
||||
{
|
||||
@@ -1102,8 +1167,35 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
|
||||
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
|
||||
if (!SM || !SM->IsAnythingSelected()) return;
|
||||
|
||||
// Filter out mandatory actors (UPS_Editor_SpawnableComponent::bIsMandatory) — they're
|
||||
// pinned to the scene by design and the delete is silently refused for them. We pre-filter
|
||||
// here so the confirmation dialog count matches what will actually be deleted, and we
|
||||
// skip the dialog entirely if nothing is deletable.
|
||||
UPS_Editor_SpawnManager* SpawnManager = EditorPC->GetSpawnManager();
|
||||
TArray<TWeakObjectPtr<AActor>> DeletableActors;
|
||||
int32 SkippedMandatory = 0;
|
||||
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
|
||||
{
|
||||
AActor* Actor = Weak.Get();
|
||||
if (!Actor) continue;
|
||||
if (SpawnManager && !SpawnManager->IsActorDeletable(Actor))
|
||||
{
|
||||
++SkippedMandatory;
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: '%s' is a mandatory actor — delete refused."),
|
||||
*Actor->GetName());
|
||||
continue;
|
||||
}
|
||||
DeletableActors.Add(Weak);
|
||||
}
|
||||
|
||||
if (DeletableActors.Num() == 0)
|
||||
{
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Delete aborted — selection contained only mandatory actor(s) (%d)."), SkippedMandatory);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show in-game confirmation dialog (runtime compatible)
|
||||
const int32 Count = SM->GetSelectedActors().Num();
|
||||
const int32 Count = DeletableActors.Num();
|
||||
const FText Message = FText::Format(
|
||||
NSLOCTEXT("PS_Editor", "DeleteConfirm", "Delete {0} selected object(s)?"),
|
||||
FText::AsNumber(Count));
|
||||
@@ -1116,15 +1208,15 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
|
||||
// Weak refs to avoid dangling pointers in the callback
|
||||
TWeakObjectPtr<APS_Editor_PlayerController> WeakPC = EditorPC;
|
||||
|
||||
Dialog->Show(Message, FPS_Editor_OnConfirmResult::CreateLambda([WeakPC](bool bConfirmed)
|
||||
Dialog->Show(Message, FPS_Editor_OnConfirmResult::CreateLambda([WeakPC, DeletableActors](bool bConfirmed)
|
||||
{
|
||||
if (!bConfirmed || !WeakPC.IsValid()) return;
|
||||
|
||||
UPS_Editor_SelectionManager* SM = WeakPC->GetSelectionManager();
|
||||
if (!SM || !SM->IsAnythingSelected()) return;
|
||||
if (!SM) return;
|
||||
|
||||
TSharedPtr<FPS_Editor_DeleteAction> Action = MakeShared<FPS_Editor_DeleteAction>();
|
||||
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
|
||||
for (const TWeakObjectPtr<AActor>& Weak : DeletableActors)
|
||||
{
|
||||
if (AActor* Actor = Weak.Get())
|
||||
{
|
||||
@@ -1196,11 +1288,6 @@ void APS_Editor_Pawn::HandleSnapToGround(const FInputActionValue& Value)
|
||||
|
||||
if (GetWorld()->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Camera, Params))
|
||||
{
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: SnapToGround - PointLoc=%s, HitZ=%.2f, HitActor=%s, HitComp=%s"),
|
||||
*PointLoc.ToString(), Hit.ImpactPoint.Z,
|
||||
Hit.GetActor() ? *Hit.GetActor()->GetName() : TEXT("null"),
|
||||
Hit.GetComponent() ? *Hit.GetComponent()->GetName() : TEXT("null"));
|
||||
|
||||
// Store state for undo
|
||||
TArray<FVector> OldPoints = Spline->GetAllPointLocations();
|
||||
|
||||
@@ -1331,6 +1418,92 @@ void APS_Editor_Pawn::HandleCancel(const FInputActionValue& Value)
|
||||
}
|
||||
}
|
||||
|
||||
void APS_Editor_Pawn::HandleFocus(const FInputActionValue& Value)
|
||||
{
|
||||
// Frame the current selection (Maya/Blender F-key behaviour). Computes the combined
|
||||
// world-space bounding box of every selected actor, then animates the camera to a
|
||||
// distance that fits the box in view while keeping the current viewing direction.
|
||||
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
|
||||
if (!EditorPC) return;
|
||||
|
||||
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
|
||||
if (!SM || !SM->IsAnythingSelected()) return;
|
||||
|
||||
FBox CombinedBox(EForceInit::ForceInit);
|
||||
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
|
||||
{
|
||||
if (AActor* Actor = Weak.Get())
|
||||
{
|
||||
const FBox ActorBox = Actor->GetComponentsBoundingBox(true);
|
||||
if (ActorBox.IsValid)
|
||||
{
|
||||
CombinedBox += ActorBox;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!CombinedBox.IsValid)
|
||||
{
|
||||
// Fallback: use actor pivots if no component reports valid bounds (rare — empty actors)
|
||||
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
|
||||
{
|
||||
if (AActor* Actor = Weak.Get())
|
||||
{
|
||||
CombinedBox += FBox(Actor->GetActorLocation(), Actor->GetActorLocation());
|
||||
}
|
||||
}
|
||||
if (!CombinedBox.IsValid) return;
|
||||
}
|
||||
|
||||
const FVector Center = CombinedBox.GetCenter();
|
||||
const FVector Extent = CombinedBox.GetExtent();
|
||||
// Sphere radius enclosing the box; clamp to a minimum so picking a point-like actor
|
||||
// (zero-size bounds) still leaves the camera at a usable distance.
|
||||
const float Radius = FMath::Max(Extent.Size(), 50.0f);
|
||||
|
||||
// Distance to fit a sphere of given radius given the camera's vertical FOV.
|
||||
float FOV = 90.0f;
|
||||
if (APlayerController* PC = Cast<APlayerController>(GetController()))
|
||||
{
|
||||
if (PC->PlayerCameraManager)
|
||||
{
|
||||
FOV = PC->PlayerCameraManager->GetFOVAngle();
|
||||
}
|
||||
}
|
||||
const float HalfFOVRad = FMath::DegreesToRadians(FOV * 0.5f);
|
||||
const float FitDistance = Radius / FMath::Max(FMath::Tan(HalfFOVRad), 0.01f);
|
||||
const float TargetDistance = FMath::Max(FitDistance * 1.4f, 100.0f); // 40% margin
|
||||
|
||||
// Keep current viewing direction; just slide the camera so Center sits TargetDistance
|
||||
// in front along that direction. Then force the rotation to look squarely at Center —
|
||||
// guards against accumulated rounding so the pivot for the next orbit is exact.
|
||||
const FVector Forward = GetActorForwardVector();
|
||||
FocusStartLocation = GetActorLocation();
|
||||
FocusStartRotation = GetActorRotation();
|
||||
FocusTargetLocation = Center - Forward * TargetDistance;
|
||||
FocusTargetRotation = (Center - FocusTargetLocation).Rotation();
|
||||
|
||||
FocusElapsed = 0.0f;
|
||||
bIsFocusing = true;
|
||||
|
||||
// Update orbit state so Alt+drag immediately after F pivots around the new center.
|
||||
OrbitFocalPoint = Center;
|
||||
OrbitDistance = TargetDistance;
|
||||
}
|
||||
|
||||
void APS_Editor_Pawn::HandleToggleNavMesh(const FInputActionValue& Value)
|
||||
{
|
||||
// Mimics UE editor's P key — toggles the runtime navmesh visualization. We route
|
||||
// through SpawnManager's session-only bShowNavMesh flag so the Settings popup
|
||||
// checkbox stays in sync.
|
||||
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
|
||||
if (!EditorPC) return;
|
||||
UPS_Editor_SpawnManager* SM = EditorPC->GetSpawnManager();
|
||||
if (!SM) return;
|
||||
|
||||
SM->bShowNavMesh = !SM->bShowNavMesh;
|
||||
PS_Editor_NavUtils::SetNavMeshVisible(GetWorld(), SM->bShowNavMesh);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode)
|
||||
@@ -1523,6 +1696,7 @@ void APS_Editor_Pawn::BeginGizmoDrag(EPS_Editor_GizmoAxis Axis)
|
||||
}
|
||||
|
||||
// Store initial sub-element state if editing a point/child
|
||||
SplinePointDragInitialRotations.Reset();
|
||||
if (ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid())
|
||||
{
|
||||
if (IPS_Editor_SplineEditable* Spline = Cast<IPS_Editor_SplineEditable>(DraggedSplineActor.Get()))
|
||||
@@ -1532,6 +1706,9 @@ void APS_Editor_Pawn::BeginGizmoDrag(EPS_Editor_GizmoAxis Axis)
|
||||
else if (DraggedSplineActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass()))
|
||||
{
|
||||
SplinePointDragInitialState = IPS_Editor_ChildMovable::Execute_GetAllChildLocations(DraggedSplineActor.Get());
|
||||
// Snapshot rotations too so the rotate gizmo can compute deltas from a fixed
|
||||
// reference and the undo system can restore them.
|
||||
SplinePointDragInitialRotations = IPS_Editor_ChildMovable::Execute_GetAllChildRotations(DraggedSplineActor.Get());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -209,9 +209,18 @@ private:
|
||||
UPROPERTY()
|
||||
TObjectPtr<UInputAction> IA_Cancel;
|
||||
|
||||
// ---- Spline point drag state ----
|
||||
UPROPERTY()
|
||||
TObjectPtr<UInputAction> IA_Focus;
|
||||
|
||||
UPROPERTY()
|
||||
TObjectPtr<UInputAction> IA_ToggleNavMesh;
|
||||
|
||||
// ---- Spline point / ChildMovable drag state ----
|
||||
TWeakObjectPtr<AActor> DraggedSplineActor;
|
||||
TArray<FVector> SplinePointDragInitialState;
|
||||
// Initial rotations captured at drag start when dragging a ChildMovable sub-element.
|
||||
// Empty for spline points (which do not have a rotation concept).
|
||||
TArray<FRotator> SplinePointDragInitialRotations;
|
||||
FVector SplinePointDragPlaneOrigin;
|
||||
FVector SplinePointDragPlaneNormal;
|
||||
|
||||
@@ -253,6 +262,17 @@ private:
|
||||
void HandleSplineMode(const FInputActionValue& Value);
|
||||
void HandleConfirm(const FInputActionValue& Value);
|
||||
void HandleCancel(const FInputActionValue& Value);
|
||||
void HandleFocus(const FInputActionValue& Value);
|
||||
void HandleToggleNavMesh(const FInputActionValue& Value);
|
||||
|
||||
// ---- Focus animation state (F key, frame selection) ----
|
||||
bool bIsFocusing = false;
|
||||
float FocusElapsed = 0.0f;
|
||||
float FocusDuration = 0.25f;
|
||||
FVector FocusStartLocation = FVector::ZeroVector;
|
||||
FVector FocusTargetLocation = FVector::ZeroVector;
|
||||
FRotator FocusStartRotation = FRotator::ZeroRotator;
|
||||
FRotator FocusTargetRotation = FRotator::ZeroRotator;
|
||||
|
||||
// ---- Helpers ----
|
||||
void CreateInputActions();
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
#include "PS_Editor_SplineEditable.h"
|
||||
#include "PS_Editor_PointPlaceable.h"
|
||||
#include "PS_Editor_EditableInterface.h"
|
||||
#include "PS_Editor_EditableComponent.h"
|
||||
#include "PS_Editor_TimelineSubsystem.h"
|
||||
#include "Engine/LevelStreamingDynamic.h"
|
||||
#include "GameFramework/Character.h"
|
||||
#include "GameFramework/CharacterMovementComponent.h"
|
||||
@@ -29,6 +31,44 @@
|
||||
#include "GameFramework/GameStateBase.h"
|
||||
#include "GameFramework/PlayerState.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
/** Resolve an editable property path ("Comp.Prop" or "Prop") on Actor to a UObject target +
|
||||
* FProperty pointer. Mirrors PS_Editor_MainWidget::FindPropertyTarget + class lookup so the
|
||||
* PlayerController can snapshot/restore values without depending on the widget. */
|
||||
static bool ResolveEditableProp(AActor* Actor, const FName& Path, UObject*& OutTarget, FProperty*& OutProp)
|
||||
{
|
||||
OutTarget = nullptr;
|
||||
OutProp = nullptr;
|
||||
if (!Actor) return false;
|
||||
|
||||
FString CompName, PropName;
|
||||
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName);
|
||||
|
||||
if (CompName.IsEmpty())
|
||||
{
|
||||
OutTarget = Actor;
|
||||
}
|
||||
else
|
||||
{
|
||||
TArray<UActorComponent*> Components;
|
||||
Actor->GetComponents(Components);
|
||||
for (UActorComponent* Comp : Components)
|
||||
{
|
||||
if (Comp && Comp->GetName() == CompName)
|
||||
{
|
||||
OutTarget = Comp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!OutTarget) return false;
|
||||
OutProp = OutTarget->GetClass()->FindPropertyByName(*PropName);
|
||||
return OutProp != nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
APS_Editor_PlayerController::APS_Editor_PlayerController()
|
||||
{
|
||||
bShowMouseCursor = true;
|
||||
@@ -98,8 +138,8 @@ void APS_Editor_PlayerController::BeginPlay()
|
||||
|
||||
if (!Sub->PendingScenarioName.IsEmpty() && SpawnManager && SceneSerializer)
|
||||
{
|
||||
bool bLoaded = SceneSerializer->LoadScene(Sub->PendingScenarioName, SpawnManager);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: LoadScene('%s') = %s"),
|
||||
bool bLoaded = SceneSerializer->LoadScenario(Sub->PendingScenarioName, SpawnManager);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: LoadScenario('%s') = %s"),
|
||||
*Sub->PendingScenarioName, bLoaded ? TEXT("OK") : TEXT("FAILED"));
|
||||
}
|
||||
Sub->PendingScenarioName.Empty();
|
||||
@@ -170,6 +210,20 @@ void APS_Editor_PlayerController::BeginPlay()
|
||||
});
|
||||
}
|
||||
|
||||
void APS_Editor_PlayerController::EndPlay(const EEndPlayReason::Type EndPlayReason)
|
||||
{
|
||||
// Reset the runtime-editor flag whenever this controller's world tears down — covers
|
||||
// OpenLevel from any caller (Play button, scenario loader BP, gameplay code that calls
|
||||
// OpenLevel directly), end of PIE, and seamless world transitions. Without this, the
|
||||
// static would stay 'true' from the previous editor session and IsInEditorMode() would
|
||||
// lie in the next world (which has no PS_Editor PlayerController). The 3 existing eager
|
||||
// resets (CloseEditor + Play buttons) remain as defense-in-depth so any check between
|
||||
// the click and the actual world tear-down also returns false.
|
||||
UPS_Editor_SceneLoader::bIsEditorModeActive = false;
|
||||
|
||||
Super::EndPlay(EndPlayReason);
|
||||
}
|
||||
|
||||
void APS_Editor_PlayerController::Tick(float DeltaTime)
|
||||
{
|
||||
Super::Tick(DeltaTime);
|
||||
@@ -275,6 +329,43 @@ void APS_Editor_PlayerController::StartSimulation()
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot editable property values so we can restore them on Stop. Game logic may mutate
|
||||
// editable properties during simulate (e.g. WalkSpeed driven by gameplay code) — restoring
|
||||
// on Stop matches the convention used for transforms. Skip properties already tracked by the
|
||||
// timeline: the timeline's RestoreBaseline owns those, double-restoring would conflict.
|
||||
SimulationSavedEditableProps.Empty();
|
||||
UPS_Editor_TimelineSubsystem* TimelineSS = GetWorld() ? GetWorld()->GetSubsystem<UPS_Editor_TimelineSubsystem>() : nullptr;
|
||||
if (SpawnManager)
|
||||
{
|
||||
for (const TWeakObjectPtr<AActor>& Weak : SpawnManager->GetSpawnedActors())
|
||||
{
|
||||
AActor* Actor = Weak.Get();
|
||||
if (!Actor) continue;
|
||||
UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>();
|
||||
if (!EditComp || EditComp->EditableProperties.Num() == 0) continue;
|
||||
|
||||
TMap<FName, FString> ActorMap;
|
||||
for (const FPS_Editor_EditablePropertyEntry& Entry : EditComp->EditableProperties)
|
||||
{
|
||||
if (TimelineSS && TimelineSS->IsPropertyTracked(Actor, Entry.Path))
|
||||
{
|
||||
continue; // timeline owns this one
|
||||
}
|
||||
UObject* Target = nullptr;
|
||||
FProperty* Prop = nullptr;
|
||||
if (!ResolveEditableProp(Actor, Entry.Path, Target, Prop)) continue;
|
||||
|
||||
FString Exported;
|
||||
Prop->ExportText_InContainer(0, Exported, Target, Target, nullptr, PPF_None);
|
||||
ActorMap.Add(Entry.Path, Exported);
|
||||
}
|
||||
if (ActorMap.Num() > 0)
|
||||
{
|
||||
SimulationSavedEditableProps.Add(Actor, MoveTemp(ActorMap));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
if (SelectionManager)
|
||||
{
|
||||
@@ -389,18 +480,23 @@ void APS_Editor_PlayerController::SetSimulationPaused(bool bPaused)
|
||||
}
|
||||
}
|
||||
|
||||
void APS_Editor_PlayerController::RequestStopSimulation()
|
||||
void APS_Editor_PlayerController::RequestStopSimulation(bool bImmediate)
|
||||
{
|
||||
if (!bSimulating) return;
|
||||
|
||||
// If a request is already scheduled, do nothing (debounce double-clicks on Stop).
|
||||
if (UWorld* World = GetWorld())
|
||||
// Skip the debounce when bImmediate (Restart wants to flush right now).
|
||||
if (!bImmediate)
|
||||
{
|
||||
if (PendingStopSimulationHandle.IsValid()) return;
|
||||
if (UWorld* World = GetWorld())
|
||||
{
|
||||
if (PendingStopSimulationHandle.IsValid()) return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fire OnEditorBeforeSimulateStop immediately on every actor so BP handlers can react
|
||||
// while the BT is still live. Then schedule the real cut for ~500ms later.
|
||||
// while the BT is still live. Then schedule the real cut (deferred) or run it now
|
||||
// (immediate, used by timeline Restart).
|
||||
if (SpawnManager)
|
||||
{
|
||||
for (const TWeakObjectPtr<AActor>& Weak : SpawnManager->GetSpawnedActors())
|
||||
@@ -413,6 +509,21 @@ void APS_Editor_PlayerController::RequestStopSimulation()
|
||||
}
|
||||
}
|
||||
|
||||
if (bImmediate)
|
||||
{
|
||||
// Cancel any pending deferred stop so it doesn't fire again later.
|
||||
if (UWorld* World = GetWorld())
|
||||
{
|
||||
if (PendingStopSimulationHandle.IsValid())
|
||||
{
|
||||
World->GetTimerManager().ClearTimer(PendingStopSimulationHandle);
|
||||
PendingStopSimulationHandle.Invalidate();
|
||||
}
|
||||
}
|
||||
StopSimulation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (UWorld* World = GetWorld())
|
||||
{
|
||||
TWeakObjectPtr<APS_Editor_PlayerController> WeakThis(this);
|
||||
@@ -502,7 +613,49 @@ void APS_Editor_PlayerController::StopSimulation()
|
||||
}
|
||||
SimulationSavedTransforms.Empty();
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Simulation stopped, transforms restored"));
|
||||
// Restore editable properties that game logic may have mutated during simulate. The timeline's
|
||||
// own RestoreBaseline already covers the animated ones (we skipped them at snapshot time), so
|
||||
// this loop only touches properties NOT owned by the timeline — no double-write. Mirrors the
|
||||
// canonical UI write path (ImportText + MarkRenderStateDirty + RepNotify + OnEditorPropertyChanged)
|
||||
// so actors reinit consistently regardless of where the value change originated.
|
||||
int32 RestoredCount = 0;
|
||||
for (auto& ActorPair : SimulationSavedEditableProps)
|
||||
{
|
||||
AActor* Actor = ActorPair.Key.Get();
|
||||
if (!Actor) continue;
|
||||
const bool bImplementsInterface = Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass());
|
||||
for (auto& PropPair : ActorPair.Value)
|
||||
{
|
||||
UObject* Target = nullptr;
|
||||
FProperty* Prop = nullptr;
|
||||
if (!ResolveEditableProp(Actor, PropPair.Key, Target, Prop)) continue;
|
||||
|
||||
Prop->ImportText_InContainer(*PropPair.Value, Target, Target, PPF_None);
|
||||
|
||||
if (UActorComponent* Comp = Cast<UActorComponent>(Target))
|
||||
{
|
||||
Comp->MarkRenderStateDirty();
|
||||
}
|
||||
|
||||
// RepNotify mirror — ImportText bypasses it, same as the UI write path.
|
||||
if (!Prop->RepNotifyFunc.IsNone())
|
||||
{
|
||||
if (UFunction* RepFunc = Target->FindFunction(Prop->RepNotifyFunc))
|
||||
{
|
||||
Target->ProcessEvent(RepFunc, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
if (bImplementsInterface)
|
||||
{
|
||||
IPS_Editor_EditableInterface::Execute_OnEditorPropertyChanged(Actor, PropPair.Key.ToString());
|
||||
}
|
||||
++RestoredCount;
|
||||
}
|
||||
}
|
||||
SimulationSavedEditableProps.Empty();
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Simulation stopped, transforms restored (%d editable properties also restored)"), RestoredCount);
|
||||
}
|
||||
|
||||
void APS_Editor_PlayerController::BeginPointPlacement(TSubclassOf<AActor> InActorClass)
|
||||
@@ -516,7 +669,7 @@ void APS_Editor_PlayerController::BeginPointPlacement(TSubclassOf<AActor> InActo
|
||||
bAppendingToActor = false;
|
||||
AppendInitialPoints.Empty();
|
||||
EditorMode = EPS_Editor_EditorMode::SplinePlacement;
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Point placement mode started (class: %s)"), *PlacementActorClass->GetName());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Point placement mode started (class: %s)"), *PlacementActorClass->GetName());
|
||||
}
|
||||
|
||||
void APS_Editor_PlayerController::BeginPointPlacementAppend(AActor* ExistingActor)
|
||||
@@ -543,7 +696,7 @@ void APS_Editor_PlayerController::BeginPointPlacementAppend(AActor* ExistingActo
|
||||
IPS_Editor_ChildMovable::Execute_SetChildHandlesVisible(ExistingActor, true);
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Editing sub-elements of %s"), *ExistingActor->GetName());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Editing sub-elements of %s"), *ExistingActor->GetName());
|
||||
}
|
||||
|
||||
void APS_Editor_PlayerController::FinishPointPlacement()
|
||||
@@ -579,7 +732,7 @@ void APS_Editor_PlayerController::FinishPointPlacement()
|
||||
SelectionManager->SelectActor(PlacementActor);
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Exited placement mode"));
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Exited placement mode"));
|
||||
bAppendingToActor = false;
|
||||
AppendInitialPoints.Empty();
|
||||
EditorMode = EPS_Editor_EditorMode::Normal;
|
||||
@@ -620,7 +773,7 @@ void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelNa
|
||||
CurrentSublevel->SetShouldBeVisible(false);
|
||||
CurrentSublevel->SetIsRequestingUnloadAndRemoval(true);
|
||||
CurrentSublevel = nullptr;
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Unloaded previous sublevel"));
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Unloaded previous sublevel"));
|
||||
}
|
||||
|
||||
// Rebuild spawn catalog: global + base-level-specific catalogs
|
||||
@@ -740,7 +893,7 @@ void APS_Editor_PlayerController::LoadBaseLevelAsSublevel(const FString& LevelNa
|
||||
ExtraSublevel->SetShouldBeLoaded(true);
|
||||
ExtraSublevel->SetShouldBeVisible(true);
|
||||
AdditionalSublevels.Add(ExtraSublevel);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Loaded additional sublevel '%s'"), *ExtraPath);
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Loaded additional sublevel '%s'"), *ExtraPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -788,7 +941,7 @@ void APS_Editor_PlayerController::DisableSublevelPostProcessMaterials()
|
||||
Actor->MarkComponentsRenderStateDirty();
|
||||
}
|
||||
}
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Deferred lightmap refresh on sublevel"));
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Deferred lightmap refresh on sublevel"));
|
||||
}, 0.5f, false);
|
||||
|
||||
APS_Editor_CameraStart* CameraStart = nullptr;
|
||||
@@ -801,7 +954,7 @@ void APS_Editor_PlayerController::DisableSublevelPostProcessMaterials()
|
||||
if (APostProcessVolume* PPVolume = Cast<APostProcessVolume>(Actor))
|
||||
{
|
||||
PPVolume->Settings.WeightedBlendables.Array.Empty();
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Disabled PP materials on '%s'"), *PPVolume->GetName());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Disabled PP materials on '%s'"), *PPVolume->GetName());
|
||||
}
|
||||
|
||||
// Find camera start marker
|
||||
@@ -819,11 +972,21 @@ void APS_Editor_PlayerController::DisableSublevelPostProcessMaterials()
|
||||
const FRotator YawOnly(CameraStart->GetActorRotation().Pitch, CameraStart->GetActorRotation().Yaw, 0.0f);
|
||||
MyPawn->SetActorLocationAndRotation(CameraStart->GetActorLocation(), YawOnly);
|
||||
SetControlRotation(YawOnly);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Camera moved to CameraStart at %s"),
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Camera moved to CameraStart at %s"),
|
||||
*CameraStart->GetActorLocation().ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-spawn mandatory actors now that the baseLevel is loaded and visible. This covers:
|
||||
// - Initial editor startup (default baseLevel loads at BeginPlay)
|
||||
// - User switching baseLevel via the UI
|
||||
// Idempotent — already-present mandatory instances are skipped. The catalog was rebuilt
|
||||
// just before LoadLevelInstance so its bIsMandatory cache is fresh.
|
||||
if (SpawnManager)
|
||||
{
|
||||
SpawnManager->EnsureMandatoryActorsPresent();
|
||||
}
|
||||
|
||||
// Delay 2s then fade in from black over 2 seconds
|
||||
if (bWaitingForSublevelFadeIn)
|
||||
{
|
||||
@@ -928,7 +1091,7 @@ void APS_Editor_PlayerController::HandleEditorClick(FVector2D ScreenPos, bool bA
|
||||
{
|
||||
const int32 InsertIdx = Spline->FindInsertIndex(TubeHit.ImpactPoint);
|
||||
Spline->InsertPoint(InsertIdx, TubeHit.ImpactPoint + FVector(0.0f, 0.0f, 5.0f));
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Inserted point at index %d during placement"), InsertIdx);
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Inserted point at index %d during placement"), InsertIdx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,58 +30,63 @@ class PS_EDITOR_API APS_Editor_PlayerController : public APlayerController
|
||||
public:
|
||||
APS_Editor_PlayerController();
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
UPS_Editor_SelectionManager* GetSelectionManager() const { return SelectionManager; }
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
UPS_Editor_UndoManager* GetUndoManager() const { return UndoManager; }
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
UPS_Editor_SpawnManager* GetSpawnManager() const { return SpawnManager; }
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
UPS_Editor_SceneSerializer* GetSceneSerializer() const { return SceneSerializer; }
|
||||
|
||||
/** Master catalog grouping all sub-catalogs (Tools, Characters, Props...). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS_Editor")
|
||||
TObjectPtr<UPS_Editor_MasterCatalog> MasterCatalogAsset;
|
||||
|
||||
/** Current base level for scene save/load filtering. Auto-detected at startup, switchable via UI. */
|
||||
UPROPERTY(BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(BlueprintReadWrite, Category = "PS_Editor")
|
||||
FString CurrentBaseLevel;
|
||||
|
||||
/** Fired when the editor Close button is pressed (after optional save).
|
||||
* Bind this in Blueprint to override the default behavior (quit application).
|
||||
* If nothing is bound, the application exits. */
|
||||
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(BlueprintAssignable, Category = "PS_Editor")
|
||||
FOnEditorClosed OnEditorClosed;
|
||||
|
||||
/** Call to close the editor. Broadcasts OnEditorClosed; if unbound, quits the app. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void CloseEditor();
|
||||
|
||||
// ---- Simulate Mode ----
|
||||
|
||||
/** Start simulation: enable AI, store actor positions, disable saving.
|
||||
* Cancels any in-flight deferred RequestStopSimulation. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void StartSimulation();
|
||||
|
||||
/** Stop simulation: disable AI, restore actor positions, re-enable saving.
|
||||
* Synchronous — fires OnEditorSimulateStop and cuts AI immediately. Callers that want
|
||||
* a BT grace period (with OnEditorBeforeSimulateStop) should use RequestStopSimulation. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void StopSimulation();
|
||||
|
||||
/** Request a deferred stop: fires OnEditorBeforeSimulateStop immediately, then schedules
|
||||
* the real StopSimulation after a grace period (500ms by default) so BT has time to
|
||||
* react to whatever the BP handler does in OnEditorBeforeSimulateStop.
|
||||
* Timeline Stop and the Simulate toolbar button both go through this. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
void RequestStopSimulation();
|
||||
* Timeline Stop and the Simulate toolbar button both go through this.
|
||||
*
|
||||
* @param bImmediate If true, fire OnEditorBeforeSimulateStop and run StopSimulation
|
||||
* synchronously in the same call (no grace period). Used by
|
||||
* timeline Restart, where we want transforms restored before the
|
||||
* subsequent Play(). Defaults to false (deferred). */
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void RequestStopSimulation(bool bImmediate = false);
|
||||
|
||||
/** True while simulation is running. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
bool IsSimulating() const { return bSimulating; }
|
||||
|
||||
/**
|
||||
@@ -89,7 +94,7 @@ public:
|
||||
* saved-transforms state. Used by the timeline's Pause so the characters freeze
|
||||
* in place instead of snapping back to their pre-play positions.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void SetSimulationPaused(bool bPaused);
|
||||
|
||||
/** Load a base level as sublevel for visual context. Unloads previous sublevel.
|
||||
@@ -98,7 +103,7 @@ public:
|
||||
* If empty, LevelName is used as-is (legacy behavior).
|
||||
* @param ExtraSublevels Additional sublevels to load alongside (e.g. always-loaded streaming levels).
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void LoadBaseLevelAsSublevel(const FString& LevelName, const FString& MapPath, const TArray<FString>& ExtraSublevels);
|
||||
|
||||
/** Overload without extra sublevels. */
|
||||
@@ -106,7 +111,7 @@ public:
|
||||
|
||||
// ---- Editor Mode (Normal / SplinePlacement) ----
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
EPS_Editor_EditorMode GetEditorMode() const { return EditorMode; }
|
||||
|
||||
/** Enter point-placement mode for any actor implementing IPS_Editor_PointPlaceable. */
|
||||
@@ -138,6 +143,7 @@ public:
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
|
||||
virtual void Tick(float DeltaTime) override;
|
||||
virtual void OnPossess(APawn* InPawn) override;
|
||||
|
||||
@@ -175,6 +181,12 @@ private:
|
||||
/** Saved transforms for all spawned actors before simulation, to restore on stop. */
|
||||
TMap<TWeakObjectPtr<AActor>, FTransform> SimulationSavedTransforms;
|
||||
|
||||
/** Saved exported-text values for editable properties (UPS_Editor_EditableComponent::EditableProperties)
|
||||
* that aren't already managed by the timeline. Game logic may mutate these during simulate;
|
||||
* StopSimulation restores them so the editor returns to the pre-Play state for ALL editable
|
||||
* properties, animated or not. Map: Actor -> { PropertyPath -> ExportedTextValue }. */
|
||||
TMap<TWeakObjectPtr<AActor>, TMap<FName, FString>> SimulationSavedEditableProps;
|
||||
|
||||
/** Timer handle for the deferred StopSimulation used by RequestStopSimulation. */
|
||||
FTimerHandle PendingStopSimulationHandle;
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ public class PS_Editor : ModuleRules
|
||||
"JsonUtilities",
|
||||
"ProceduralMeshComponent",
|
||||
"DesktopPlatform",
|
||||
"AIModule"
|
||||
"AIModule",
|
||||
"NavigationSystem"
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[]
|
||||
|
||||
@@ -120,22 +120,33 @@ namespace PS_Editor_GroundSnap
|
||||
return false;
|
||||
}
|
||||
|
||||
// Measure the bbox bottom relative to the pivot. Use COLLIDING components only so
|
||||
// a Character's capsule drives the calculation — not its skeletal mesh (often with
|
||||
// collision disabled) or attached weapons / child actors whose meshes may extend
|
||||
// below the capsule and artificially lift the pawn by that overflow.
|
||||
// Falls back to the full actor bounds if no colliding component is found (e.g. a
|
||||
// pure-mesh prop without collision — rare but possible).
|
||||
// Measure the bbox bottom relative to the pivot.
|
||||
//
|
||||
// Primary path — colliding components only: a Character's capsule drives the
|
||||
// calculation, ignoring the skeletal mesh (often with collision disabled) or any
|
||||
// attached weapons / child actors whose meshes might extend below the capsule and
|
||||
// artificially lift the pawn by that overflow. Formula: pivot is placed so the
|
||||
// collision-bbox bottom rests on the ground.
|
||||
//
|
||||
// Fallback — no colliding components found: the actor is treated as a pure visual
|
||||
// marker (VR start zone, placement helper, or a BP that enables collision only
|
||||
// when entering editor mode and hasn't done so yet). For these, we don't trust
|
||||
// the full-bounds bbox (it may wrap helper meshes far below the pivot and push
|
||||
// the object absurdly high). Instead we anchor the PIVOT directly on the ground —
|
||||
// which is what the designer authored the pivot for on these markers.
|
||||
FVector BoundsOrigin, BoundsExtent;
|
||||
Actor->GetActorBounds(/*bOnlyCollidingComponents*/ true, BoundsOrigin, BoundsExtent);
|
||||
if (BoundsExtent.IsNearlyZero())
|
||||
const bool bUsedFallback = BoundsExtent.IsNearlyZero();
|
||||
if (bUsedFallback)
|
||||
{
|
||||
// Read the full bounds too — only to know where "above the actor" is for the
|
||||
// ray start position, not to anchor the snap.
|
||||
Actor->GetActorBounds(false, BoundsOrigin, BoundsExtent);
|
||||
}
|
||||
const float ActorZ = Actor->GetActorLocation().Z;
|
||||
const float BBoxBottomZ = BoundsOrigin.Z - BoundsExtent.Z;
|
||||
const float BBoxTopZ = BoundsOrigin.Z + BoundsExtent.Z;
|
||||
const float PivotAboveBottom = ActorZ - BBoxBottomZ;
|
||||
const float PivotAboveBottom = bUsedFallback ? 0.0f : (ActorZ - BBoxBottomZ);
|
||||
|
||||
float ExtraOffset = 0.0f;
|
||||
if (UPS_Editor_SpawnableComponent* SpawnComp = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>())
|
||||
|
||||
@@ -195,7 +195,7 @@ void UPS_Editor_SelectionManager::SelectActor(AActor* Actor)
|
||||
Spline->SetSelected(true);
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Selected %s"), *Actor->GetName());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Selected %s"), *Actor->GetName());
|
||||
}
|
||||
|
||||
void UPS_Editor_SelectionManager::DeselectActor(AActor* Actor)
|
||||
@@ -235,7 +235,7 @@ void UPS_Editor_SelectionManager::DeselectActor(AActor* Actor)
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Deselected %s"), *Actor->GetName());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Deselected %s"), *Actor->GetName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ void UPS_Editor_SelectionManager::ClearSelection()
|
||||
|
||||
if (Count > 0)
|
||||
{
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Cleared selection (%d actors)"), Count);
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Cleared selection (%d actors)"), Count);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ void FPS_Editor_SplinePointAction::Execute()
|
||||
else if (SplineActor.IsValid() && SplineActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass()))
|
||||
{
|
||||
IPS_Editor_ChildMovable::Execute_SetAllChildLocations(SplineActor.Get(), NewPoints);
|
||||
// Apply rotations only if the BP captured them (otherwise we'd reset to identity).
|
||||
if (NewRotations.Num() > 0)
|
||||
{
|
||||
IPS_Editor_ChildMovable::Execute_SetAllChildRotations(SplineActor.Get(), NewRotations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +30,10 @@ void FPS_Editor_SplinePointAction::Undo()
|
||||
else if (SplineActor.IsValid() && SplineActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass()))
|
||||
{
|
||||
IPS_Editor_ChildMovable::Execute_SetAllChildLocations(SplineActor.Get(), OldPoints);
|
||||
if (OldRotations.Num() > 0)
|
||||
{
|
||||
IPS_Editor_ChildMovable::Execute_SetAllChildRotations(SplineActor.Get(), OldRotations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +98,7 @@ void UPS_Editor_UndoManager::RecordAction(TSharedPtr<FPS_Editor_Action> Action)
|
||||
|
||||
PruneHistory();
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Recorded action: %s (stack size: %d)"),
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Recorded action: %s (stack size: %d)"),
|
||||
*Action->GetDescription(), ActionStack.Num());
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,11 @@ struct FPS_Editor_SplinePointAction : public FPS_Editor_Action
|
||||
TWeakObjectPtr<AActor> SplineActor;
|
||||
TArray<FVector> OldPoints;
|
||||
TArray<FVector> NewPoints;
|
||||
// ChildMovable-only: rotation snapshots. Empty for spline points and for ChildMovable BPs
|
||||
// that don't implement rotation. Execute/Undo only apply rotations when both arrays match
|
||||
// the points array length.
|
||||
TArray<FRotator> OldRotations;
|
||||
TArray<FRotator> NewRotations;
|
||||
FString Description;
|
||||
|
||||
virtual void Execute() override;
|
||||
|
||||
@@ -61,4 +61,17 @@ struct FPS_Editor_SceneData
|
||||
/** Global timeline data (animation tracks + duration). Empty on legacy scenes. */
|
||||
UPROPERTY()
|
||||
FPS_Editor_TimelineData Timeline;
|
||||
|
||||
/** If true, the level's nav mesh is switched to Dynamic runtime generation when this
|
||||
* scenario loads — both in the runtime editor (so spawn / move actors update nav live)
|
||||
* and in gameplay (for scenarios that need runtime-built navigation). False keeps the
|
||||
* cooked Static nav, which has zero runtime cost. Default: false. */
|
||||
UPROPERTY()
|
||||
bool bUseDynamicNavigation = false;
|
||||
|
||||
/** Project-specific scenario type as a uint8 (index into UPS_Editor_MasterCatalog::ScenarioTypeEnum).
|
||||
* Selected by the user via the Settings popup dropdown. Defaults to 0 (= first enum entry) for
|
||||
* legacy scenarios that were saved before this field existed. */
|
||||
UPROPERTY()
|
||||
uint8 ScenarioType = 0;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "PS_Editor_TimelineSubsystem.h"
|
||||
#include "PS_Editor_SpawnManager.h"
|
||||
#include "PS_Editor_PlayerController.h"
|
||||
#include "PS_Editor_NavUtils.h"
|
||||
#include "AIController.h"
|
||||
#include "BrainComponent.h"
|
||||
#include "JsonObjectConverter.h"
|
||||
@@ -48,11 +49,13 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray<AActor*>& OutActors, bool bStripEditorComponents, EPS_Editor_SceneLoadFilter Filter)
|
||||
TArray<TWeakObjectPtr<AActor>> UPS_Editor_SceneLoader::TrackedSpawnedActors;
|
||||
|
||||
bool UPS_Editor_SceneLoader::LoadScenario(const UObject* WorldContextObject, const FString& ScenarioName, TArray<AActor*>& OutActors, bool bStripEditorComponents, EPS_Editor_SceneLoadFilter Filter)
|
||||
{
|
||||
OutActors.Empty();
|
||||
|
||||
if (!WorldContextObject || SceneName.IsEmpty())
|
||||
if (!WorldContextObject || ScenarioName.IsEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -64,7 +67,7 @@ bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const
|
||||
}
|
||||
|
||||
// Read JSON file
|
||||
const FString FilePath = GetSceneFilePath(SceneName);
|
||||
const FString FilePath = GetSceneFilePath(ScenarioName);
|
||||
FString JsonString;
|
||||
if (!FFileHelper::LoadFileToString(JsonString, *FilePath))
|
||||
{
|
||||
@@ -80,11 +83,22 @@ bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!LoadSceneFromData(World, SceneData, OutActors, Filter))
|
||||
if (!LoadScenarioFromData(World, SceneData, OutActors, Filter))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Track every spawned actor so UnloadScenario() can dispose of them later — this is the
|
||||
// briefing-flow hook (load preload markers, then unload on scenario change). Gameplay
|
||||
// callers that never unload don't pay a price beyond a small TWeakObjectPtr array.
|
||||
for (AActor* Actor : OutActors)
|
||||
{
|
||||
if (Actor)
|
||||
{
|
||||
TrackedSpawnedActors.Add(Actor);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip editor-only components for clean gameplay
|
||||
if (bStripEditorComponents)
|
||||
{
|
||||
@@ -93,11 +107,6 @@ bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const
|
||||
if (!Actor) continue;
|
||||
|
||||
TArray<UActorComponent*> ToRemove;
|
||||
for (UActorComponent* Comp : Actor->GetComponentsByInterface(UPS_Editor_PointPlaceable::StaticClass()))
|
||||
{
|
||||
// Don't remove — PointPlaceable is on the actor itself, not a component
|
||||
}
|
||||
|
||||
if (UActorComponent* Spawnable = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>())
|
||||
{
|
||||
ToRemove.Add(Spawnable);
|
||||
@@ -123,14 +132,54 @@ bool UPS_Editor_SceneLoader::LoadScene(const UObject* WorldContextObject, const
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_SceneData& SceneData, TArray<AActor*>& OutActors, EPS_Editor_SceneLoadFilter Filter)
|
||||
int32 UPS_Editor_SceneLoader::UnloadScenario(const UObject* WorldContextObject)
|
||||
{
|
||||
int32 Destroyed = 0;
|
||||
for (TWeakObjectPtr<AActor>& Weak : TrackedSpawnedActors)
|
||||
{
|
||||
if (AActor* Actor = Weak.Get())
|
||||
{
|
||||
Actor->Destroy();
|
||||
++Destroyed;
|
||||
}
|
||||
// else: already gone (GC, manual destroy, level reload) — skip silently
|
||||
}
|
||||
TrackedSpawnedActors.Reset();
|
||||
|
||||
if (Destroyed > 0)
|
||||
{
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor SceneLoader: UnloadScenario destroyed %d actor(s)"), Destroyed);
|
||||
}
|
||||
return Destroyed;
|
||||
}
|
||||
|
||||
bool UPS_Editor_SceneLoader::LoadScenarioFromData(UWorld* World, const FPS_Editor_SceneData& SceneData, TArray<AActor*>& OutActors, EPS_Editor_SceneLoadFilter Filter)
|
||||
{
|
||||
if (!World)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UPS_Editor_TimelineSubsystem* TimelineSS = World->GetSubsystem<UPS_Editor_TimelineSubsystem>();
|
||||
// Defensive: reaching the gameplay-side SceneLoader means we are NOT in the runtime
|
||||
// editor (the editor uses UPS_Editor_SceneSerializer instead). Force the static flag
|
||||
// false now so subsequent IsInEditorMode() checks reflect reality even if a previous
|
||||
// PIE session left the static at 'true' — PIE keeps C++ statics across stop/start, and
|
||||
// a forced PIE-stop or BP-driven OpenLevel can skip the EndPlay reset on the editor PC.
|
||||
bIsEditorModeActive = false;
|
||||
|
||||
// Apply the scenario's dynamic-navigation flag BEFORE spawning actors. If the scenario
|
||||
// needs runtime nav generation, switching the level's RecastNavMesh to Dynamic now
|
||||
// means the actors we're about to spawn will dirty + rebuild tiles around them. Cooked
|
||||
// Static nav is preserved on disk for scenarios that don't need this.
|
||||
PS_Editor_NavUtils::SetDynamicNavigationEnabled(World, SceneData.bUseDynamicNavigation);
|
||||
|
||||
// PreloadOnly is the briefing path: only player-placement markers / briefing props
|
||||
// should spawn, and the briefing scene runs its OWN timeline (don't push the scenario's
|
||||
// timeline data or auto-play it). Skip the timeline subsystem entirely in that case.
|
||||
const bool bSkipTimeline = (Filter == EPS_Editor_SceneLoadFilter::PreloadOnly);
|
||||
UPS_Editor_TimelineSubsystem* TimelineSS = bSkipTimeline
|
||||
? nullptr
|
||||
: World->GetSubsystem<UPS_Editor_TimelineSubsystem>();
|
||||
|
||||
int32 SpawnedCount = 0;
|
||||
|
||||
@@ -140,7 +189,8 @@ bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_S
|
||||
AActor* SpawnedActor = SpawnActorFromData(World, ActorData, Filter);
|
||||
if (SpawnedActor)
|
||||
{
|
||||
// Register the stable ID so timeline tracks can find this actor
|
||||
// Register the stable ID so timeline tracks can find this actor.
|
||||
// Only when timeline is active for this load (PreloadOnly skips the subsystem).
|
||||
if (TimelineSS && ActorData.ActorId.IsValid())
|
||||
{
|
||||
TimelineSS->RegisterActorId(SpawnedActor, ActorData.ActorId);
|
||||
@@ -206,8 +256,8 @@ bool UPS_Editor_SceneLoader::LoadSceneFromData(UWorld* World, const FPS_Editor_S
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor SceneLoader: Loaded %d actors from scene '%s'"),
|
||||
SpawnedCount, *SceneData.SceneName);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor SceneLoader: Loaded %d actor(s) from scenario '%s' [filter=%d]"),
|
||||
SpawnedCount, *SceneData.SceneName, (int32)Filter);
|
||||
return SpawnedCount > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#include "Kismet/BlueprintFunctionLibrary.h"
|
||||
#include "PS_Editor_SceneLoader.generated.h"
|
||||
|
||||
/** Filter for selective scene loading. */
|
||||
/** Filter for selective scenario loading. */
|
||||
UENUM(BlueprintType)
|
||||
enum class EPS_Editor_SceneLoadFilter : uint8
|
||||
{
|
||||
@@ -15,20 +15,27 @@ enum class EPS_Editor_SceneLoadFilter : uint8
|
||||
};
|
||||
|
||||
/**
|
||||
* Standalone scene loader for PS_Editor.
|
||||
* Loads a JSON scene file and spawns all actors with their properties and spline data.
|
||||
* Standalone scenario loader for PS_Editor.
|
||||
* A "scenario" is a .pss file describing actors + properties + timeline saved by the runtime
|
||||
* editor. This loader spawns those actors into an already-loaded UE level — it does NOT
|
||||
* change the current UE level. Spawning is additive: existing world content is preserved.
|
||||
*
|
||||
* This is the entry point for GAME MODE usage — it does NOT require
|
||||
* PS_Editor's PlayerController, SelectionManager, Gizmo, or UI.
|
||||
* Just call LoadScene() from your GameMode's BeginPlay.
|
||||
* Just call LoadScenario() from your GameMode's BeginPlay.
|
||||
*
|
||||
* Usage:
|
||||
* void AMyGameMode::BeginPlay()
|
||||
* {
|
||||
* Super::BeginPlay();
|
||||
* TArray<AActor*> SpawnedActors;
|
||||
* UPS_Editor_SceneLoader::LoadScene(GetWorld(), TEXT("MyScene"), SpawnedActors);
|
||||
* UPS_Editor_SceneLoader::LoadScenario(GetWorld(), TEXT("MyScenario"), SpawnedActors);
|
||||
* }
|
||||
*
|
||||
* Briefing usage (player placement markers only, no timeline animation):
|
||||
* UPS_Editor_SceneLoader::UnloadScenario(this);
|
||||
* UPS_Editor_SceneLoader::LoadScenario(this, TEXT("MyScenario"), Out, true,
|
||||
* EPS_Editor_SceneLoadFilter::PreloadOnly);
|
||||
*/
|
||||
UCLASS()
|
||||
class PS_EDITOR_API UPS_Editor_SceneLoader : public UBlueprintFunctionLibrary
|
||||
@@ -37,59 +44,74 @@ class PS_EDITOR_API UPS_Editor_SceneLoader : public UBlueprintFunctionLibrary
|
||||
|
||||
public:
|
||||
/**
|
||||
* Load a scene from a JSON file and spawn all actors.
|
||||
* Load a scenario from a .pss file and spawn its actors into the current world.
|
||||
*
|
||||
* @param World The world to spawn actors into.
|
||||
* @param SceneName Scene name (without .json extension).
|
||||
* @param OutActors Filled with all spawned actors.
|
||||
* The call is additive — existing world content is preserved. Spawned actors are tracked
|
||||
* internally so UnloadScenario() can clean them up; this lets you switch scenarios at
|
||||
* runtime (typical briefing flow). When Filter == PreloadOnly, the timeline subsystem is
|
||||
* NOT touched (no SetData, no auto-play, no ActorId registration) so a briefing scene's
|
||||
* own timeline keeps running untouched.
|
||||
*
|
||||
* @param ScenarioName Scenario name (without .pss extension).
|
||||
* @param OutActors Filled with all spawned actors (also tracked internally).
|
||||
* @param bStripEditorComponents If true, removes SpawnableComponent and EditableComponent after spawn (cleaner for gameplay).
|
||||
* @return True if the scene was loaded successfully.
|
||||
* @param Filter Which actors to spawn. PreloadOnly = briefing-only, skips timeline setup.
|
||||
* @return True if the scenario was loaded successfully.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor", meta = (WorldContext = "WorldContextObject"))
|
||||
static bool LoadScene(const UObject* WorldContextObject, const FString& SceneName, TArray<AActor*>& OutActors,
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor", meta = (WorldContext = "WorldContextObject"))
|
||||
static bool LoadScenario(const UObject* WorldContextObject, const FString& ScenarioName, TArray<AActor*>& OutActors,
|
||||
bool bStripEditorComponents = true, EPS_Editor_SceneLoadFilter Filter = EPS_Editor_SceneLoadFilter::All);
|
||||
|
||||
/**
|
||||
* Load scene from an already-parsed SceneData struct.
|
||||
* Useful if you want to parse the JSON yourself or modify data before spawning.
|
||||
* Destroy every actor previously spawned via LoadScenario(). No-op if nothing was loaded
|
||||
* or all tracked actors have already been GC'd / manually destroyed.
|
||||
* Call this before LoadScenario() when switching scenarios.
|
||||
* @return Number of actors destroyed.
|
||||
*/
|
||||
static bool LoadSceneFromData(UWorld* World, const FPS_Editor_SceneData& SceneData, TArray<AActor*>& OutActors,
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor", meta = (WorldContext = "WorldContextObject"))
|
||||
static int32 UnloadScenario(const UObject* WorldContextObject);
|
||||
|
||||
/**
|
||||
* Load scenario actors from an already-parsed SceneData struct.
|
||||
* Useful if you want to parse the .pss JSON yourself or modify data before spawning.
|
||||
*/
|
||||
static bool LoadScenarioFromData(UWorld* World, const FPS_Editor_SceneData& SceneData, TArray<AActor*>& OutActors,
|
||||
EPS_Editor_SceneLoadFilter Filter = EPS_Editor_SceneLoadFilter::All);
|
||||
|
||||
/**
|
||||
* Get the directory where scenes are stored.
|
||||
* Get the directory where scenarios are stored.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
static FString GetScenesDirectory();
|
||||
|
||||
/**
|
||||
* Get the full file path for a scene name.
|
||||
* Get the full file path for a scenario name.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
static FString GetSceneFilePath(const FString& SceneName);
|
||||
|
||||
/** Get the full file path for a scene name and its base level. Uses level-specific subdirectory. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
/** Get the full file path for a scenario name and its base level. Uses level-specific subdirectory. */
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
static FString GetSceneFilePathForLevel(const FString& SceneName, const FString& LevelName);
|
||||
|
||||
/**
|
||||
* Get all saved scene names. Optionally filter by level name.
|
||||
* @param LevelFilter If not empty, only return scenes associated with this level.
|
||||
* Get all saved scenario names. Optionally filter by level name.
|
||||
* @param LevelFilter If not empty, only return scenarios associated with this level.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
static TArray<FString> GetSavedSceneNames(const FString& LevelFilter = TEXT(""));
|
||||
|
||||
/**
|
||||
* Get the level name associated with a saved scene (reads the JSON header without spawning).
|
||||
* Get the level name associated with a saved scenario (reads the JSON header without spawning).
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
static FString GetSceneLevelName(const FString& SceneName);
|
||||
|
||||
/**
|
||||
* Returns true while a scene is being loaded (actors are being spawned from JSON).
|
||||
* Returns true while a scenario is being loaded (actors are being spawned from JSON).
|
||||
* Use in BeginPlay to skip default initialization — wait for OnPropertiesLoaded instead.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS_Editor")
|
||||
static bool IsLoadingFromScene(const AActor* Actor);
|
||||
|
||||
/** Static flag: true while SceneLoader or SceneSerializer is spawning actors. */
|
||||
@@ -99,13 +121,13 @@ public:
|
||||
static bool bIsEditorModeActive;
|
||||
|
||||
/** Returns true if the PS_Editor runtime editor is currently active. Callable from any BP. */
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS_Editor")
|
||||
static bool IsInEditorMode() { return bIsEditorModeActive; }
|
||||
|
||||
/**
|
||||
* Get all saved scenes for the current level.
|
||||
* Get all saved scenarios for the current level.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor", meta = (WorldContext = "WorldContextObject"))
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor", meta = (WorldContext = "WorldContextObject"))
|
||||
static TArray<FString> GetSavedSceneNamesForCurrentLevel(const UObject* WorldContextObject);
|
||||
|
||||
private:
|
||||
@@ -114,4 +136,8 @@ private:
|
||||
|
||||
/** Apply custom editable properties to a spawned actor. */
|
||||
static void ApplyCustomProperties(AActor* Actor, const TMap<FString, FString>& CustomProperties);
|
||||
|
||||
/** Actors spawned via LoadScenario(), tracked so UnloadScenario() can find them.
|
||||
* TWeakObjectPtr handles GC / manual destruction gracefully (skipped at unload time). */
|
||||
static TArray<TWeakObjectPtr<AActor>> TrackedSpawnedActors;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "GameFramework/CharacterMovementComponent.h"
|
||||
#include "PS_Editor_SplineEditable.h"
|
||||
#include "PS_Editor_TimelineSubsystem.h"
|
||||
#include "PS_Editor_NavUtils.h"
|
||||
#include "JsonObjectConverter.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/Paths.h"
|
||||
@@ -110,7 +111,7 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
|
||||
if (IPS_Editor_SplineEditable* Spline = Cast<IPS_Editor_SplineEditable>(Actor))
|
||||
{
|
||||
Spline->ExportToCustomProperties(ActorData.CustomProperties);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Exported spline with %d points, class=%s"),
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Exported spline with %d points, class=%s"),
|
||||
Spline->GetNumPoints(), *ActorData.ClassPath);
|
||||
}
|
||||
|
||||
@@ -166,6 +167,13 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
|
||||
SceneData.Timeline = TimelineSS->GetData();
|
||||
}
|
||||
|
||||
// Per-scenario settings — read from the SpawnManager's session state.
|
||||
if (SpawnManager)
|
||||
{
|
||||
SceneData.bUseDynamicNavigation = SpawnManager->bUseDynamicNavigation;
|
||||
SceneData.ScenarioType = SpawnManager->ScenarioType;
|
||||
}
|
||||
|
||||
// Convert to JSON
|
||||
FString JsonString;
|
||||
if (!FJsonObjectConverter::UStructToJsonObjectString(SceneData, JsonString, 0, 0, 0, nullptr, true))
|
||||
@@ -194,7 +202,7 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager)
|
||||
bool UPS_Editor_SceneSerializer::LoadScenario(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager)
|
||||
{
|
||||
if (!SpawnManager || SceneName.IsEmpty())
|
||||
{
|
||||
@@ -438,6 +446,24 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
|
||||
}
|
||||
}
|
||||
|
||||
// Per-scenario settings — push into the SpawnManager's session state and apply
|
||||
// the dynamic-navigation switch to the level's RecastNavMesh actors right away.
|
||||
if (SpawnManager)
|
||||
{
|
||||
SpawnManager->bUseDynamicNavigation = SceneData.bUseDynamicNavigation;
|
||||
SpawnManager->ScenarioType = SceneData.ScenarioType;
|
||||
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOuter()))
|
||||
{
|
||||
PS_Editor_NavUtils::SetDynamicNavigationEnabled(EditorPC->GetWorld(), SpawnManager->bUseDynamicNavigation);
|
||||
}
|
||||
|
||||
// Re-add any mandatory actors missing from the loaded scene. Handles legacy saves
|
||||
// that predate the bIsMandatory flag — the mandatory actor is auto-spawned at the
|
||||
// baseLevel's MandatorySpawnAnchor. If all mandatory actors were already in the JSON,
|
||||
// this is a no-op.
|
||||
SpawnManager->EnsureMandatoryActorsPresent();
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scene loaded: %s (%d actors)"), *SceneName, SpawnedCount);
|
||||
return true;
|
||||
}
|
||||
@@ -494,6 +520,14 @@ void UPS_Editor_SceneSerializer::ClearScene(UPS_Editor_SpawnManager* SpawnManage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Mandatory-actor auto-spawn is intentionally NOT done here. ClearScene is called
|
||||
// from three contexts: (1) "New Scenario" button (we want mandatory spawn — handled in the
|
||||
// widget after ClearScene returns), (2) LoadScenario before re-spawning saved actors (we
|
||||
// don't want it — the JSON itself contains the mandatory actor, and there's a final
|
||||
// EnsureMandatoryActorsPresent at the end of LoadScenario that handles legacy saves), and
|
||||
// (3) BaseLevel switch (we don't want it — the new baseLevel isn't loaded yet, the
|
||||
// MandatoryAnchor isn't in the world; the post-OnLevelShown hook handles that).
|
||||
}
|
||||
|
||||
FString UPS_Editor_SceneSerializer::GetSceneFilePath(const FString& SceneName) const
|
||||
|
||||
@@ -21,8 +21,8 @@ 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);
|
||||
/** Load a scenario from a .pss file. Destroys current spawned actors and re-spawns from file. */
|
||||
bool LoadScenario(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager);
|
||||
|
||||
/** Get a list of all saved scene names (without .json extension). */
|
||||
TArray<FString> GetSavedSceneNames() const;
|
||||
|
||||
@@ -25,47 +25,67 @@ class PS_EDITOR_API IPS_Editor_ChildMovable
|
||||
|
||||
public:
|
||||
/** Number of movable sub-elements. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
int32 GetMovableChildCount() const;
|
||||
|
||||
/** World location of the sub-element at the given index. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
FVector GetChildLocation(int32 Index) const;
|
||||
|
||||
/** Move the sub-element at the given index to a new world position. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
void MoveChild(int32 Index, const FVector& NewWorldPosition);
|
||||
|
||||
/** Return all sub-element positions (used for undo snapshots). */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
TArray<FVector> GetAllChildLocations() const;
|
||||
|
||||
/** Restore all sub-element positions (used for undo). */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
void SetAllChildLocations(const TArray<FVector>& Positions);
|
||||
|
||||
/** World rotation of the sub-element at the given index. Implement to constrain the
|
||||
* rotation axes you actually expose (e.g. yaw only) — the plugin gizmo writes whatever
|
||||
* the user drags, so any clamping must happen in RotateChild on the BP side. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
FRotator GetChildRotation(int32 Index) const;
|
||||
|
||||
/** Apply a new world rotation to the sub-element at the given index. The BP impl is the
|
||||
* right place to drop unsupported axes (e.g. keep only Yaw, force Pitch/Roll to zero). */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
void RotateChild(int32 Index, const FRotator& NewWorldRotation);
|
||||
|
||||
/** Return all sub-element rotations (used for undo snapshots). Empty array allowed if
|
||||
* rotation is not supported on this actor — the undo system then ignores rotation. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
TArray<FRotator> GetAllChildRotations() const;
|
||||
|
||||
/** Restore all sub-element rotations (used for undo). */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
void SetAllChildRotations(const TArray<FRotator>& Rotations);
|
||||
|
||||
/** Index of the sub-element under the cursor ray (-1 if none).
|
||||
* Implement with sphere-ray intersection or any hit detection that suits your visuals. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
int32 GetChildIndexUnderCursor(const FVector& RayOrigin, const FVector& RayDirection) const;
|
||||
|
||||
/** Show or hide visual handles for the sub-elements. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
void SetChildHandlesVisible(bool bVisible);
|
||||
|
||||
/** Highlight a specific sub-element (e.g. change handle color). */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
void HighlightChild(int32 Index);
|
||||
|
||||
/** Clear all sub-element highlights. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
void ClearChildHighlight();
|
||||
|
||||
/** Export sub-element positions to the CustomProperties map for scene saving. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
void ExportChildLocations(TMap<FString, FString>& OutProperties) const;
|
||||
|
||||
/** Import sub-element positions from the CustomProperties map on scene load. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor|Child Movable")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable")
|
||||
void ImportChildLocations(const TMap<FString, FString>& Properties);
|
||||
};
|
||||
|
||||
@@ -181,7 +181,7 @@ void UPS_Editor_EditableComponent::PostEditChangeProperty(FPropertyChangedEvent&
|
||||
FPS_Editor_EditablePropertyEntry NewEntry;
|
||||
NewEntry.Path = FullPath;
|
||||
EditableProperties.Add(NewEntry);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Added editable property: %s"), *FullPath.ToString());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Added editable property: %s"), *FullPath.ToString());
|
||||
}
|
||||
|
||||
AddProperty = NAME_None; // Reset for next pick
|
||||
|
||||
@@ -15,25 +15,25 @@ struct FPS_Editor_EditablePropertyEntry
|
||||
/**
|
||||
* Property path: "ComponentName.PropertyName" or just "PropertyName" for actor-level.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
FName Path;
|
||||
|
||||
/** Display name override for the runtime editor UI. If empty, the property name is used. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
FString DisplayName;
|
||||
|
||||
/**
|
||||
* If true, calls OnEditorPropertiesChanged (IPS_Editor_EditableInterface)
|
||||
* after changing this property, so the actor can re-run its initialization logic.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
bool bNeedsReinit = false;
|
||||
|
||||
/**
|
||||
* If true, the actor is destroyed and respawned with all current properties applied,
|
||||
* then OnEditorPropertiesChanged is called. Use when reinit alone is not enough.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
bool bNeedsRespawn = false;
|
||||
|
||||
/**
|
||||
@@ -43,7 +43,7 @@ struct FPS_Editor_EditablePropertyEntry
|
||||
* Replicated (ideally ReplicatedUsing=OnRep_X) — the server-side timeline writes the value
|
||||
* and standard UE replication pushes it to clients.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
bool bAnimatable = false;
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ struct FPS_Editor_EditablePropertyEntry
|
||||
* 2. Pick a property from "Add Property" (filtered to that component)
|
||||
* 3. It auto-adds to "Editable Properties" as "Component.Property"
|
||||
*/
|
||||
UCLASS(ClassGroup = "ASTERION|PS Editor", meta = (BlueprintSpawnableComponent, DisplayName = "PS Editor Editable"))
|
||||
UCLASS(ClassGroup = "PS Editor", meta = (BlueprintSpawnableComponent, DisplayName = "PS Editor Editable"))
|
||||
class PS_EDITOR_API UPS_Editor_EditableComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
@@ -15,7 +15,7 @@ struct FPS_Editor_PropertyOption
|
||||
GENERATED_BODY()
|
||||
|
||||
/** Label shown in the runtime editor combo box. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS_Editor")
|
||||
FString DisplayName;
|
||||
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ struct FPS_Editor_PropertyOption
|
||||
* the full class path; for an enum you'd use the NameString; for a free FString,
|
||||
* any value you want stored.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS_Editor")
|
||||
FString Value;
|
||||
};
|
||||
|
||||
@@ -51,21 +51,21 @@ public:
|
||||
* The new value is already applied when this is called.
|
||||
* @param PropertyPath The property that changed (e.g. "CharacterType" or "MyComponent.SomeProperty").
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
void OnEditorPropertyChanged(const FString& PropertyPath);
|
||||
|
||||
/**
|
||||
* Called when a property with bNeedsReinit is changed.
|
||||
* The new value is already applied. Use this to re-run initialization logic.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
void OnEditorNeedsReinit();
|
||||
|
||||
/**
|
||||
* Called on the NEW actor after a respawn triggered by bNeedsRespawn.
|
||||
* All properties have been applied. Use this for post-respawn setup.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
void OnEditorNeedsRespawn();
|
||||
|
||||
/**
|
||||
@@ -73,7 +73,7 @@ public:
|
||||
* Fires both in editor (SceneSerializer) and in gameplay (SceneLoader).
|
||||
* Use this to re-initialize your actor based on the loaded property values.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
void OnPropertiesLoaded();
|
||||
|
||||
/**
|
||||
@@ -81,7 +81,7 @@ public:
|
||||
* Use this to show/hide editor-only helpers (VR guides, placement aids, debug visuals, etc.).
|
||||
* @param bIsEditing True when entering edit mode, false when leaving (Play, return to game).
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
void OnEditorModeChanged(bool bIsEditing);
|
||||
|
||||
/**
|
||||
@@ -90,7 +90,7 @@ public:
|
||||
* the hood). Use this to kick off behaviour that should only happen while AI is live
|
||||
* (spawn FX, reset state counters, unlock input, etc.).
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
void OnEditorSimulateStart();
|
||||
|
||||
/**
|
||||
@@ -102,7 +102,7 @@ public:
|
||||
* Timing: fires at the "AI cut" moment, AFTER any grace period the timeline applied
|
||||
* (typically 500ms after Stop was clicked). BT is still live but about to be disabled.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
void OnEditorSimulateStop();
|
||||
|
||||
/**
|
||||
@@ -130,7 +130,7 @@ public:
|
||||
* event still fires + grace period still granted)
|
||||
* Does NOT fire for direct StopSimulation calls (rare — e.g. forced editor close).
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
void OnEditorBeforeSimulateStop();
|
||||
|
||||
/**
|
||||
@@ -138,7 +138,7 @@ public:
|
||||
* Use this to update visuals dynamically based on property changes, animate helpers, etc.
|
||||
* @param DeltaTime Time since last frame.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
void OnEditorTick(float DeltaTime);
|
||||
|
||||
/**
|
||||
@@ -150,7 +150,7 @@ public:
|
||||
* @return List of option strings. Empty = use default widget / fall through to the
|
||||
* structured variant below.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
TArray<FString> GetCustomOptionsForProperty(const FString& PropertyPath) const;
|
||||
|
||||
/**
|
||||
@@ -164,7 +164,7 @@ public:
|
||||
* @param PropertyPath The property path (e.g. "WeaponName" or "MyComponent.WeaponType").
|
||||
* @return List of display/value pairs. Empty = fall back to the flat-string variant.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
TArray<FPS_Editor_PropertyOption> GetCustomOptionsWithValuesForProperty(const FString& PropertyPath) const;
|
||||
|
||||
/**
|
||||
@@ -185,6 +185,6 @@ public:
|
||||
* @param PropertyPath The property path (e.g. "Weapon" or "MyComponent.WeaponType").
|
||||
* @return DisplayName to show in the combo, or empty string to fall back.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
|
||||
FString GetCurrentDisplayForProperty(const FString& PropertyPath) const;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
#include "PS_Editor_FloorCutManager.h"
|
||||
#include "PS_Editor_MandatoryAnchor.h"
|
||||
#include "PS_Editor_SceneLoader.h"
|
||||
#include "Components/PrimitiveComponent.h"
|
||||
#include "EngineUtils.h"
|
||||
#include "Engine/World.h"
|
||||
|
||||
bool UPS_Editor_FloorCutManager::ShouldCreateSubsystem(UObject* Outer) const
|
||||
{
|
||||
// Only create on real game worlds — skip preview / inactive worlds.
|
||||
if (const UWorld* World = Cast<UWorld>(Outer))
|
||||
{
|
||||
const EWorldType::Type WT = World->WorldType;
|
||||
return WT == EWorldType::Game || WT == EWorldType::PIE || WT == EWorldType::Editor;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::Initialize(FSubsystemCollectionBase& Collection)
|
||||
{
|
||||
Super::Initialize(Collection);
|
||||
bEnabled = false;
|
||||
HeightAboveAnchor = 200.f;
|
||||
bAnchorZCached = false;
|
||||
CachedAnchorZ = 0.f;
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::Deinitialize()
|
||||
{
|
||||
// Restore everything we touched, so a world tear-down leaves a clean state.
|
||||
RestoreAll();
|
||||
Snapshots.Empty();
|
||||
Super::Deinitialize();
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::SetEnabled(bool bInEnabled)
|
||||
{
|
||||
// Editor-only feature: silently ignore in gameplay mode. The widgets gate calls already,
|
||||
// but this is a belt-and-braces safety so nothing leaks into a SceneLoader-driven runtime.
|
||||
if (!UPS_Editor_SceneLoader::IsInEditorMode())
|
||||
{
|
||||
if (bEnabled)
|
||||
{
|
||||
RestoreAll();
|
||||
bEnabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (bEnabled == bInEnabled) return;
|
||||
|
||||
bEnabled = bInEnabled;
|
||||
if (bEnabled)
|
||||
{
|
||||
ApplyCutToWorld();
|
||||
}
|
||||
else
|
||||
{
|
||||
RestoreAll();
|
||||
}
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::SetHeightAboveAnchor(float HeightCm)
|
||||
{
|
||||
// Clamp to a sane range matching the UI slider (0–1000 cm).
|
||||
HeightCm = FMath::Clamp(HeightCm, 0.f, 10000.f);
|
||||
if (FMath::IsNearlyEqual(HeightAboveAnchor, HeightCm)) return;
|
||||
|
||||
HeightAboveAnchor = HeightCm;
|
||||
if (bEnabled)
|
||||
{
|
||||
ApplyCutToWorld();
|
||||
}
|
||||
}
|
||||
|
||||
float UPS_Editor_FloorCutManager::GetCutWorldZ() const
|
||||
{
|
||||
if (!bAnchorZCached)
|
||||
{
|
||||
// Const-cast acceptable: lazy cache only, no observable mutation of "logical" state.
|
||||
const_cast<UPS_Editor_FloorCutManager*>(this)->ResolveAnchorZ();
|
||||
}
|
||||
return CachedAnchorZ + HeightAboveAnchor;
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::ReapplyCut()
|
||||
{
|
||||
if (!bEnabled) return;
|
||||
ApplyCutToWorld();
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::OnActorSpawned(AActor* Actor)
|
||||
{
|
||||
if (!bEnabled || !Actor) return;
|
||||
|
||||
ResolveAnchorZ();
|
||||
const float CutZ = CachedAnchorZ + HeightAboveAnchor;
|
||||
|
||||
TArray<UPrimitiveComponent*> Prims;
|
||||
Actor->GetComponents<UPrimitiveComponent>(Prims);
|
||||
for (UPrimitiveComponent* P : Prims)
|
||||
{
|
||||
ApplyCutToComponent(P, CutZ);
|
||||
}
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::InvalidateAnchorCache()
|
||||
{
|
||||
bAnchorZCached = false;
|
||||
}
|
||||
|
||||
float UPS_Editor_FloorCutManager::ResolveAnchorZ()
|
||||
{
|
||||
bAnchorZCached = true;
|
||||
CachedAnchorZ = 0.f;
|
||||
|
||||
UWorld* World = GetWorld();
|
||||
if (!World) return CachedAnchorZ;
|
||||
|
||||
for (TActorIterator<APS_Editor_MandatoryAnchor> It(World); It; ++It)
|
||||
{
|
||||
if (APS_Editor_MandatoryAnchor* Anchor = *It)
|
||||
{
|
||||
CachedAnchorZ = Anchor->GetActorLocation().Z;
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: FloorCut anchor Z = %f"), CachedAnchorZ);
|
||||
return CachedAnchorZ;
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: FloorCut — no APS_Editor_MandatoryAnchor found, using world Z=0 as reference"));
|
||||
return CachedAnchorZ;
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::ApplyCutToWorld()
|
||||
{
|
||||
UWorld* World = GetWorld();
|
||||
if (!World) return;
|
||||
|
||||
ResolveAnchorZ();
|
||||
const float CutZ = CachedAnchorZ + HeightAboveAnchor;
|
||||
|
||||
int32 Hidden = 0;
|
||||
int32 Visible = 0;
|
||||
|
||||
for (TActorIterator<AActor> ActorIt(World); ActorIt; ++ActorIt)
|
||||
{
|
||||
AActor* Actor = *ActorIt;
|
||||
if (!Actor) continue;
|
||||
|
||||
// Never hide the anchor itself (it's hidden in game anyway, but defensive).
|
||||
if (Actor->IsA<APS_Editor_MandatoryAnchor>()) continue;
|
||||
|
||||
TArray<UPrimitiveComponent*> Prims;
|
||||
Actor->GetComponents<UPrimitiveComponent>(Prims);
|
||||
for (UPrimitiveComponent* P : Prims)
|
||||
{
|
||||
if (!P) continue;
|
||||
|
||||
const FBoxSphereBounds B = P->Bounds;
|
||||
const float MinZ = B.Origin.Z - B.BoxExtent.Z;
|
||||
if (MinZ > CutZ)
|
||||
{
|
||||
ApplyCutToComponent(P, CutZ);
|
||||
++Hidden;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Component sits at or straddles the plane → must be in its restored state.
|
||||
RestoreComponent(P);
|
||||
++Visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: FloorCut applied — CutZ=%.1f, hidden=%d, kept=%d, anchor=%.1f, height=%.1f"),
|
||||
CutZ, Hidden, Visible, CachedAnchorZ, HeightAboveAnchor);
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::ApplyCutToComponent(UPrimitiveComponent* Comp, float CutZ)
|
||||
{
|
||||
if (!Comp) return;
|
||||
|
||||
const FBoxSphereBounds B = Comp->Bounds;
|
||||
const float MinZ = B.Origin.Z - B.BoxExtent.Z;
|
||||
if (MinZ <= CutZ)
|
||||
{
|
||||
// Below the plane — make sure it is in its restored state (caller may not have).
|
||||
RestoreComponent(Comp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot before first mutation, so we can restore the exact original state.
|
||||
TWeakObjectPtr<UPrimitiveComponent> Key(Comp);
|
||||
if (!Snapshots.Contains(Key))
|
||||
{
|
||||
FCutSnapshot Snap;
|
||||
Snap.bWasVisible = Comp->IsVisible();
|
||||
Snap.CollisionEnabled = Comp->GetCollisionEnabled();
|
||||
Snapshots.Add(Key, Snap);
|
||||
}
|
||||
|
||||
Comp->SetVisibility(false, /*bPropagateToChildren=*/false);
|
||||
Comp->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::RestoreComponent(UPrimitiveComponent* Comp)
|
||||
{
|
||||
if (!Comp) return;
|
||||
TWeakObjectPtr<UPrimitiveComponent> Key(Comp);
|
||||
const FCutSnapshot* Snap = Snapshots.Find(Key);
|
||||
if (!Snap) return;
|
||||
|
||||
Comp->SetVisibility(Snap->bWasVisible, /*bPropagateToChildren=*/false);
|
||||
Comp->SetCollisionEnabled(Snap->CollisionEnabled);
|
||||
Snapshots.Remove(Key);
|
||||
}
|
||||
|
||||
void UPS_Editor_FloorCutManager::RestoreAll()
|
||||
{
|
||||
for (auto It = Snapshots.CreateIterator(); It; ++It)
|
||||
{
|
||||
UPrimitiveComponent* Comp = It.Key().Get();
|
||||
if (Comp)
|
||||
{
|
||||
Comp->SetVisibility(It.Value().bWasVisible, /*bPropagateToChildren=*/false);
|
||||
Comp->SetCollisionEnabled(It.Value().CollisionEnabled);
|
||||
}
|
||||
}
|
||||
Snapshots.Empty();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Subsystems/WorldSubsystem.h"
|
||||
#include "Engine/EngineTypes.h"
|
||||
#include "UObject/WeakObjectPtr.h"
|
||||
#include "PS_Editor_FloorCutManager.generated.h"
|
||||
|
||||
class UPrimitiveComponent;
|
||||
class APS_Editor_MandatoryAnchor;
|
||||
|
||||
/**
|
||||
* Editor-only horizontal "floor cut" plane.
|
||||
*
|
||||
* Hides every UPrimitiveComponent whose world-space bounds-min Z is strictly above
|
||||
* AnchorZ + HeightAboveAnchor, by toggling per-component visibility and collision.
|
||||
* The original state is snapshotted before any modification and restored on disable
|
||||
* (or on plugin exit), so the feature is non-destructive.
|
||||
*
|
||||
* Editor-only by design — the gameplay runtime loaded via SceneLoader::LoadScene never
|
||||
* touches this subsystem. UI in the editor widgets is the only consumer.
|
||||
*
|
||||
* Limitations:
|
||||
* - Components straddling the cut plane (minZ <= cut <= maxZ) stay fully visible.
|
||||
* Per-pixel cuts would require modifying master materials, which we explicitly
|
||||
* don't want.
|
||||
* - Anchor Z is sampled once and cached. If the user moves the APS_Editor_MandatoryAnchor
|
||||
* at runtime, call InvalidateAnchorCache() to re-sample.
|
||||
*
|
||||
* Usage:
|
||||
* - Get via UWorld::GetSubsystem<UPS_Editor_FloorCutManager>(World)
|
||||
* - SetEnabled(true), SetHeightAboveAnchor(200.f)
|
||||
* - OnActorSpawned(NewActor) when SpawnManager spawns a new actor while active
|
||||
*/
|
||||
UCLASS()
|
||||
class PS_EDITOR_API UPS_Editor_FloorCutManager : public UWorldSubsystem
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
// UWorldSubsystem
|
||||
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
|
||||
virtual void Deinitialize() override;
|
||||
virtual bool ShouldCreateSubsystem(UObject* Outer) const override;
|
||||
|
||||
/** Enable / disable the cut. On disable, all snapshotted components are restored. */
|
||||
void SetEnabled(bool bInEnabled);
|
||||
bool IsEnabled() const { return bEnabled; }
|
||||
|
||||
/** Set the cut height in centimeters above the MandatoryAnchor. Re-applies immediately
|
||||
* if the cut is enabled. */
|
||||
void SetHeightAboveAnchor(float HeightCm);
|
||||
float GetHeightAboveAnchor() const { return HeightAboveAnchor; }
|
||||
|
||||
/** Computed world Z of the cut plane (AnchorZ + HeightAboveAnchor). Returns
|
||||
* TNumericLimits<float>::Max() if no anchor is found. */
|
||||
float GetCutWorldZ() const;
|
||||
|
||||
/** Force a re-application of the current cut on all current components.
|
||||
* Cheap (no allocation if components haven't moved). */
|
||||
void ReapplyCut();
|
||||
|
||||
/** Called by the SpawnManager whenever it spawns a new actor while the cut is
|
||||
* active, so the new actor's primitives are evaluated against the cut. */
|
||||
void OnActorSpawned(AActor* Actor);
|
||||
|
||||
/** Drop the cached anchor Z (re-sampled on next ReapplyCut). */
|
||||
void InvalidateAnchorCache();
|
||||
|
||||
private:
|
||||
/** Per-component snapshot of the state we tweak, so we can restore exactly. */
|
||||
struct FCutSnapshot
|
||||
{
|
||||
bool bWasVisible = true;
|
||||
TEnumAsByte<ECollisionEnabled::Type> CollisionEnabled = ECollisionEnabled::QueryAndPhysics;
|
||||
};
|
||||
|
||||
/** Apply the cut to a single primitive (snapshot if needed, then hide). */
|
||||
void ApplyCutToComponent(UPrimitiveComponent* Comp, float CutZ);
|
||||
/** Restore a single primitive from its snapshot, if any. */
|
||||
void RestoreComponent(UPrimitiveComponent* Comp);
|
||||
|
||||
/** Walk the world and apply the cut to every primitive. */
|
||||
void ApplyCutToWorld();
|
||||
/** Restore every snapshotted primitive. */
|
||||
void RestoreAll();
|
||||
|
||||
/** Find the world Z of the first MandatoryAnchor in the world, or fall back to 0.
|
||||
* Result is cached in CachedAnchorZ. */
|
||||
float ResolveAnchorZ();
|
||||
|
||||
bool bEnabled = false;
|
||||
float HeightAboveAnchor = 200.f; // cm — default 2m as agreed with user
|
||||
|
||||
bool bAnchorZCached = false;
|
||||
float CachedAnchorZ = 0.f;
|
||||
|
||||
/** Snapshots of components we have modified, keyed by weak pointer so destroyed
|
||||
* primitives are simply ignored on restore. */
|
||||
TMap<TWeakObjectPtr<UPrimitiveComponent>, FCutSnapshot> Snapshots;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
#include "PS_Editor_MandatoryAnchor.h"
|
||||
#include "Components/SceneComponent.h"
|
||||
#include "Components/ArrowComponent.h"
|
||||
#include "Components/BillboardComponent.h"
|
||||
#include "UObject/ConstructorHelpers.h"
|
||||
|
||||
APS_Editor_MandatoryAnchor::APS_Editor_MandatoryAnchor()
|
||||
{
|
||||
PrimaryActorTick.bCanEverTick = false;
|
||||
|
||||
USceneComponent* Root = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
|
||||
RootComponent = Root;
|
||||
|
||||
#if WITH_EDITORONLY_DATA
|
||||
SpriteComponent = CreateDefaultSubobject<UBillboardComponent>(TEXT("Sprite"));
|
||||
SpriteComponent->SetupAttachment(Root);
|
||||
SpriteComponent->SetHiddenInGame(true);
|
||||
SpriteComponent->bIsScreenSizeScaled = true;
|
||||
SpriteComponent->ScreenSize = 0.0015f;
|
||||
|
||||
// Target-point icon — clearly distinct from APS_Editor_CameraStart's note icon.
|
||||
// (Engine/EditorResources/S_Pivot does not exist in UE 5.5 — using S_TargetPoint instead,
|
||||
// which fits the "positional anchor marker" semantic and avoids the cook-time
|
||||
// "Failed to find /Engine/EditorResources/S_Pivot" CDO error.)
|
||||
static ConstructorHelpers::FObjectFinder<UTexture2D> IconFinder(TEXT("/Engine/EditorResources/S_TargetPoint"));
|
||||
if (IconFinder.Succeeded())
|
||||
{
|
||||
SpriteComponent->SetSprite(IconFinder.Object);
|
||||
}
|
||||
|
||||
ArrowComponent = CreateDefaultSubobject<UArrowComponent>(TEXT("Arrow"));
|
||||
ArrowComponent->SetupAttachment(Root);
|
||||
ArrowComponent->ArrowColor = FColor(255, 200, 60); // orange so it pops in the viewport
|
||||
ArrowComponent->ArrowSize = 1.5f;
|
||||
ArrowComponent->bIsScreenSizeScaled = true;
|
||||
ArrowComponent->SetHiddenInGame(true);
|
||||
#endif
|
||||
|
||||
// The anchor is purely a marker — no rendering in PIE / packaged
|
||||
SetActorHiddenInGame(true);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "PS_Editor_MandatoryAnchor.generated.h"
|
||||
|
||||
/**
|
||||
* Standalone marker actor that defines where mandatory actors
|
||||
* (UPS_Editor_SpawnableComponent::bIsMandatory) auto-spawn when a new scenario is created
|
||||
* or when a legacy save is missing one.
|
||||
*
|
||||
* Usage:
|
||||
* - Place ONE instance of this actor in each baseLevel
|
||||
* - Position it freely — independent from APS_Editor_CameraStart
|
||||
* - Save the level
|
||||
*
|
||||
* The actor is hidden in game / PIE; it has a visible orange arrow + sprite in the level
|
||||
* editor for placement. SpawnManager::EnsureMandatoryActorsPresent reads its world transform.
|
||||
*/
|
||||
UCLASS(BlueprintType, Blueprintable, meta = (DisplayName = "PS Editor Mandatory Anchor"))
|
||||
class PS_EDITOR_API APS_Editor_MandatoryAnchor : public AActor
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
APS_Editor_MandatoryAnchor();
|
||||
|
||||
#if WITH_EDITORONLY_DATA
|
||||
protected:
|
||||
/** Editor-only billboard sprite so the actor is easy to pick in the level. */
|
||||
UPROPERTY(VisibleAnywhere, Category = "PS_Editor")
|
||||
TObjectPtr<class UBillboardComponent> SpriteComponent;
|
||||
|
||||
/** Editor-only forward arrow visualizing the anchor's orientation. */
|
||||
UPROPERTY(VisibleAnywhere, Category = "PS_Editor")
|
||||
TObjectPtr<class UArrowComponent> ArrowComponent;
|
||||
#endif
|
||||
};
|
||||
@@ -0,0 +1,348 @@
|
||||
#include "PS_Editor_NavUtils.h"
|
||||
#include "NavigationSystem.h"
|
||||
#include "NavigationData.h"
|
||||
#include "NavMesh/RecastNavMesh.h"
|
||||
#include "Engine/World.h"
|
||||
#include "Engine/GameViewportClient.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
#include "DrawDebugHelpers.h"
|
||||
#include "ProceduralMeshComponent.h"
|
||||
#include "Materials/MaterialInterface.h"
|
||||
#include "UObject/ConstructorHelpers.h"
|
||||
#include "UObject/SoftObjectPath.h"
|
||||
|
||||
namespace PS_Editor_NavUtils
|
||||
{
|
||||
// ANavigationData::RuntimeGeneration and bEnableDrawing are both protected. Local
|
||||
// using-trick subclass to re-publish them — the C++ access check uses the static
|
||||
// type, so a static_cast through this exposing class lets us read/write them
|
||||
// without modifying the engine.
|
||||
struct FNavDataAccessor : public ANavigationData
|
||||
{
|
||||
using ANavigationData::RuntimeGeneration;
|
||||
using ANavigationData::bEnableDrawing;
|
||||
};
|
||||
|
||||
// Recast-specific draw flags exposer.
|
||||
struct FRecastAccessor : public ARecastNavMesh
|
||||
{
|
||||
using ARecastNavMesh::bDrawTriangleEdges;
|
||||
using ARecastNavMesh::bDrawPolyEdges;
|
||||
using ARecastNavMesh::bDrawNavMeshEdges;
|
||||
using ARecastNavMesh::bDrawTileBounds;
|
||||
};
|
||||
|
||||
void SetRuntimeGenerationMode(UWorld* World, ERuntimeGenerationType Mode)
|
||||
{
|
||||
if (!World) return;
|
||||
|
||||
TArray<AActor*> NavMeshActors;
|
||||
UGameplayStatics::GetAllActorsOfClass(World, ANavigationData::StaticClass(), NavMeshActors);
|
||||
for (AActor* A : NavMeshActors)
|
||||
{
|
||||
if (ANavigationData* NavData = Cast<ANavigationData>(A))
|
||||
{
|
||||
FNavDataAccessor* Accessor = static_cast<FNavDataAccessor*>(NavData);
|
||||
if (Accessor->RuntimeGeneration != Mode)
|
||||
{
|
||||
Accessor->RuntimeGeneration = Mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SetDynamicNavigationEnabled(UWorld* World, bool bDynamic)
|
||||
{
|
||||
SetRuntimeGenerationMode(World,
|
||||
bDynamic ? ERuntimeGenerationType::Dynamic : ERuntimeGenerationType::Static);
|
||||
}
|
||||
|
||||
void SetNavMeshVisible(UWorld* World, bool bVisible)
|
||||
{
|
||||
if (!World) return;
|
||||
|
||||
// PIE/runtime nav visualization in UE has multiple gates that all need to align:
|
||||
//
|
||||
// 1. ANavigationData::bEnableDrawing — controls whether the rendering
|
||||
// component creates a scene proxy at all.
|
||||
// 2. ARecastNavMesh::bDrawNavMeshEdges (+ bDrawPolyEdges, bDrawTileBounds, etc.)
|
||||
// — control what the proxy actually paints. With bEnableDrawing=true but every
|
||||
// bDraw* flag false, the proxy is created but draws nothing.
|
||||
// 3. GameViewportClient::EngineShowFlags.Navigation — the show-flag the
|
||||
// `show navigation` console command toggles. The proxy is only rendered
|
||||
// when this flag is on.
|
||||
// 4. ReregisterAllComponents — if bEnableDrawing was false at construction
|
||||
// time, the rendering component never created its proxy. Toggling
|
||||
// bEnableDrawing alone won't recreate it; we have to re-register the
|
||||
// components so the proxy is rebuilt with the new flag value.
|
||||
//
|
||||
// All four are required for runtime visualization to actually appear.
|
||||
TArray<AActor*> NavMeshActors;
|
||||
UGameplayStatics::GetAllActorsOfClass(World, ANavigationData::StaticClass(), NavMeshActors);
|
||||
for (AActor* A : NavMeshActors)
|
||||
{
|
||||
if (ANavigationData* NavData = Cast<ANavigationData>(A))
|
||||
{
|
||||
FNavDataAccessor* Accessor = static_cast<FNavDataAccessor*>(NavData);
|
||||
Accessor->bEnableDrawing = bVisible;
|
||||
|
||||
// Recast-specific: enable enough draw flags that the proxy actually paints
|
||||
// something visible. Edges only — tile bounds are noisy.
|
||||
if (ARecastNavMesh* Recast = Cast<ARecastNavMesh>(NavData))
|
||||
{
|
||||
FRecastAccessor* RA = static_cast<FRecastAccessor*>(Recast);
|
||||
RA->bDrawNavMeshEdges = bVisible;
|
||||
RA->bDrawPolyEdges = bVisible;
|
||||
RA->bDrawTriangleEdges = bVisible;
|
||||
}
|
||||
|
||||
// Force proxy recreation. MarkComponentsRenderStateDirty alone is not
|
||||
// enough when the proxy was never created (bEnableDrawing=false at init).
|
||||
NavData->ReregisterAllComponents();
|
||||
}
|
||||
}
|
||||
|
||||
if (UGameViewportClient* GVC = World->GetGameViewport())
|
||||
{
|
||||
GVC->EngineShowFlags.SetNavigation(bVisible);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Custom navmesh visualization (works without engine modifications) ----
|
||||
//
|
||||
// Cache the geometry between frames to avoid the expensive GetDebugGeometry call every
|
||||
// tick. We refresh it on a small interval (default 0.5s) — enough for visual feedback
|
||||
// during dynamic nav rebuilds without burning CPU on static maps.
|
||||
struct FCachedNavMeshGeo
|
||||
{
|
||||
TArray<FVector> Vertices;
|
||||
TArray<int32> TriIndices; // groups of 3 — A B C
|
||||
};
|
||||
static TArray<FCachedNavMeshGeo> GCachedNavMeshes;
|
||||
static float GTimeSinceRefresh = 9999.f; // force initial refresh
|
||||
|
||||
// Lift the cached navmesh geometry slightly so the wireframe + translucent surface don't
|
||||
// z-fight with the floor mesh underneath (the navmesh sits exactly on the walkable surface
|
||||
// per the cell height, which causes shimmering with translucent materials especially).
|
||||
// 2cm is enough to suppress visual artifacts on typical level geometry without making the
|
||||
// overlay look detached from the floor.
|
||||
static constexpr float NavMeshDrawZOffset = 2.0f;
|
||||
|
||||
// ---- Surface visualization (procedural mesh + user-supplied translucent material) ----
|
||||
//
|
||||
// We spawn a single persistent actor with one UProceduralMeshComponent that holds the
|
||||
// triangulated navmesh. Its material is loaded once from a hardcoded soft path:
|
||||
// /PS_Editor/M_PS_Editor_NavMeshSurface
|
||||
// Users create that material in the plugin Content folder with their own settings
|
||||
// (translucent, two-sided, color, etc). If the asset is missing, we log a one-shot warning
|
||||
// and the surface stays hidden — the wireframe still draws so the feature degrades
|
||||
// gracefully rather than crashing.
|
||||
static const TCHAR* GNavMeshSurfaceMaterialPath = TEXT("/PS_Editor/M_PS_Editor_NavMeshSurface.M_PS_Editor_NavMeshSurface");
|
||||
static TWeakObjectPtr<AActor> GSurfaceActor;
|
||||
static TWeakObjectPtr<UProceduralMeshComponent> GSurfaceMesh;
|
||||
static TWeakObjectPtr<UMaterialInterface> GSurfaceMaterial;
|
||||
static bool GSurfaceMaterialMissingWarned = false;
|
||||
|
||||
static UMaterialInterface* LoadSurfaceMaterial()
|
||||
{
|
||||
if (GSurfaceMaterial.IsValid()) return GSurfaceMaterial.Get();
|
||||
|
||||
// Always retry — LoadObject is cheap when the asset is already loaded, and we want
|
||||
// the load to succeed even if the material was created AFTER the first attempt
|
||||
// (Live Coding hot-reload, or user just created the asset). Suppressing retries
|
||||
// breaks "create the material, hit P, see it work" without restarting the editor.
|
||||
UMaterialInterface* Mat = LoadObject<UMaterialInterface>(nullptr, GNavMeshSurfaceMaterialPath);
|
||||
if (Mat)
|
||||
{
|
||||
GSurfaceMaterial = Mat;
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: NavMesh surface material loaded from %s"), GNavMeshSurfaceMaterialPath);
|
||||
GSurfaceMaterialMissingWarned = false;
|
||||
return Mat;
|
||||
}
|
||||
|
||||
if (!GSurfaceMaterialMissingWarned)
|
||||
{
|
||||
GSurfaceMaterialMissingWarned = true;
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: NavMesh surface material not found at %s. ")
|
||||
TEXT("Create a translucent material at that path to enable surface visualization. ")
|
||||
TEXT("Falling back to wireframe-only."), GNavMeshSurfaceMaterialPath);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static UProceduralMeshComponent* EnsureSurfaceComponent(UWorld* World)
|
||||
{
|
||||
if (!World) return nullptr;
|
||||
if (GSurfaceMesh.IsValid()) return GSurfaceMesh.Get();
|
||||
|
||||
// Spawn a minimal actor and add a UProceduralMeshComponent at runtime. The actor is
|
||||
// transient — never saved with the scenario, never selectable in the editor.
|
||||
FActorSpawnParameters Params;
|
||||
Params.ObjectFlags = RF_Transient;
|
||||
Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
|
||||
AActor* Actor = World->SpawnActor<AActor>(AActor::StaticClass(), FTransform::Identity, Params);
|
||||
if (!Actor) return nullptr;
|
||||
#if WITH_EDITOR
|
||||
Actor->SetActorLabel(TEXT("PS_Editor_NavMeshSurfaceViz"));
|
||||
#endif
|
||||
|
||||
UProceduralMeshComponent* Mesh = NewObject<UProceduralMeshComponent>(Actor, TEXT("NavMeshSurface"));
|
||||
Mesh->SetMobility(EComponentMobility::Movable);
|
||||
Mesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
|
||||
Mesh->bUseAsyncCooking = false;
|
||||
Mesh->SetCastShadow(false);
|
||||
Mesh->SetGenerateOverlapEvents(false);
|
||||
Mesh->RegisterComponent();
|
||||
Actor->SetRootComponent(Mesh);
|
||||
|
||||
GSurfaceActor = Actor;
|
||||
GSurfaceMesh = Mesh;
|
||||
return Mesh;
|
||||
}
|
||||
|
||||
static void DestroySurfaceActor()
|
||||
{
|
||||
if (AActor* Actor = GSurfaceActor.Get())
|
||||
{
|
||||
Actor->Destroy();
|
||||
}
|
||||
GSurfaceActor.Reset();
|
||||
GSurfaceMesh.Reset();
|
||||
}
|
||||
|
||||
void ClearNavMeshDebugCache()
|
||||
{
|
||||
GCachedNavMeshes.Empty();
|
||||
GTimeSinceRefresh = 9999.f;
|
||||
DestroySurfaceActor();
|
||||
}
|
||||
|
||||
void DrawNavMeshDebug(UWorld* World, float DeltaTime, float RefreshIntervalSeconds)
|
||||
{
|
||||
if (!World) return;
|
||||
|
||||
GTimeSinceRefresh += DeltaTime;
|
||||
if (GTimeSinceRefresh >= RefreshIntervalSeconds || GCachedNavMeshes.Num() == 0)
|
||||
{
|
||||
GTimeSinceRefresh = 0.f;
|
||||
GCachedNavMeshes.Empty();
|
||||
|
||||
TArray<AActor*> NavMeshActors;
|
||||
UGameplayStatics::GetAllActorsOfClass(World, ARecastNavMesh::StaticClass(), NavMeshActors);
|
||||
for (AActor* A : NavMeshActors)
|
||||
{
|
||||
if (ARecastNavMesh* Recast = Cast<ARecastNavMesh>(A))
|
||||
{
|
||||
Recast->BeginBatchQuery();
|
||||
|
||||
FCachedNavMeshGeo Cached;
|
||||
|
||||
// Iterate every tile and pull its debug geometry. UE 5.5 dropped the
|
||||
// single-call GetDebugGeometry; only the per-tile variant remains.
|
||||
// As of 5.5 the int32-tile-index overload is deprecated — use the
|
||||
// FNavTileRef overload via GetAllNavMeshTiles.
|
||||
TArray<FNavTileRef> TileRefs;
|
||||
Recast->GetAllNavMeshTiles(TileRefs);
|
||||
for (const FNavTileRef& TileRef : TileRefs)
|
||||
{
|
||||
FRecastDebugGeometry Geo;
|
||||
Recast->GetDebugGeometryForTile(Geo, TileRef);
|
||||
|
||||
const int32 BaseIdx = Cached.Vertices.Num();
|
||||
Cached.Vertices.Reserve(BaseIdx + Geo.MeshVerts.Num());
|
||||
for (const FVector& V : Geo.MeshVerts)
|
||||
{
|
||||
Cached.Vertices.Add(V + FVector(0.f, 0.f, NavMeshDrawZOffset));
|
||||
}
|
||||
for (int32 AreaIdx = 0; AreaIdx < RECAST_MAX_AREAS; ++AreaIdx)
|
||||
{
|
||||
const TArray<int32>& AreaIndices = Geo.AreaIndices[AreaIdx];
|
||||
Cached.TriIndices.Reserve(Cached.TriIndices.Num() + AreaIndices.Num());
|
||||
for (int32 Idx : AreaIndices)
|
||||
{
|
||||
Cached.TriIndices.Add(BaseIdx + Idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Recast->FinishBatchQuery();
|
||||
GCachedNavMeshes.Add(MoveTemp(Cached));
|
||||
}
|
||||
}
|
||||
|
||||
// Cache rebuilt — also refresh the procedural surface mesh so the translucent
|
||||
// overlay tracks live navmesh changes. We merge every navmesh's geometry into a
|
||||
// single section to keep the proc mesh component lightweight (one section, one
|
||||
// material, one draw call).
|
||||
if (UProceduralMeshComponent* Surface = EnsureSurfaceComponent(World))
|
||||
{
|
||||
TArray<FVector> Vertices;
|
||||
TArray<int32> Triangles;
|
||||
for (const FCachedNavMeshGeo& Cached : GCachedNavMeshes)
|
||||
{
|
||||
const int32 BaseIdx = Vertices.Num();
|
||||
Vertices.Append(Cached.Vertices);
|
||||
Triangles.Reserve(Triangles.Num() + Cached.TriIndices.Num());
|
||||
for (int32 Idx : Cached.TriIndices)
|
||||
{
|
||||
Triangles.Add(BaseIdx + Idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Empty arrays for normals/UVs/colors/tangents — the unlit translucent material
|
||||
// the user provides doesn't need them. ProcMesh treats empty arrays as "skip
|
||||
// this stream", which is fine for an unlit overlay.
|
||||
static const TArray<FVector> EmptyNormals;
|
||||
static const TArray<FVector2D> EmptyUVs;
|
||||
static const TArray<FColor> EmptyColors;
|
||||
static const TArray<FProcMeshTangent> EmptyTangents;
|
||||
|
||||
if (Vertices.Num() > 0 && Triangles.Num() > 0)
|
||||
{
|
||||
Surface->CreateMeshSection(/*SectionIndex=*/ 0, Vertices, Triangles,
|
||||
EmptyNormals, EmptyUVs, EmptyColors, EmptyTangents,
|
||||
/*bCreateCollision=*/ false);
|
||||
|
||||
UMaterialInterface* Mat = LoadSurfaceMaterial();
|
||||
if (Mat)
|
||||
{
|
||||
Surface->SetMaterial(0, Mat);
|
||||
}
|
||||
Surface->SetVisibility(true);
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: NavMesh surface refreshed — %d verts, %d tris, material=%s"),
|
||||
Vertices.Num(), Triangles.Num() / 3,
|
||||
Mat ? *Mat->GetName() : TEXT("<none, wireframe-only>"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Surface->ClearAllMeshSections();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-frame draw of cached geometry. Lifetime=0 + bPersistent=false means each call
|
||||
// only paints for one frame, so we get clean redraw every tick.
|
||||
const FColor EdgeColor(40, 220, 90); // green
|
||||
for (const FCachedNavMeshGeo& Cached : GCachedNavMeshes)
|
||||
{
|
||||
const int32 NumTris = Cached.TriIndices.Num() / 3;
|
||||
for (int32 t = 0; t < NumTris; ++t)
|
||||
{
|
||||
const int32 I0 = Cached.TriIndices[t * 3 + 0];
|
||||
const int32 I1 = Cached.TriIndices[t * 3 + 1];
|
||||
const int32 I2 = Cached.TriIndices[t * 3 + 2];
|
||||
if (!Cached.Vertices.IsValidIndex(I0) || !Cached.Vertices.IsValidIndex(I1) || !Cached.Vertices.IsValidIndex(I2))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const FVector& V0 = Cached.Vertices[I0];
|
||||
const FVector& V1 = Cached.Vertices[I1];
|
||||
const FVector& V2 = Cached.Vertices[I2];
|
||||
DrawDebugLine(World, V0, V1, EdgeColor, /*bPersistent=*/ false, /*LifeTime=*/ 0.f, /*DepthPriority=*/ 0, /*Thickness=*/ 1.0f);
|
||||
DrawDebugLine(World, V1, V2, EdgeColor, false, 0.f, 0, 1.0f);
|
||||
DrawDebugLine(World, V2, V0, EdgeColor, false, 0.f, 0, 1.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
class UWorld;
|
||||
enum class ERuntimeGenerationType : uint8;
|
||||
|
||||
/**
|
||||
* Centralised helpers for switching the level's RecastNavMesh runtime-generation mode and
|
||||
* toggling the runtime navmesh visualization. Used by:
|
||||
* - The runtime editor "Settings" popup (live toggle of dynamic nav + show navmesh)
|
||||
* - SceneLoader (gameplay path: applies the scenario's bUseDynamicNavigation flag)
|
||||
* - Scenario save/load (persists bUseDynamicNavigation in JSON)
|
||||
*/
|
||||
namespace PS_Editor_NavUtils
|
||||
{
|
||||
/**
|
||||
* Switch every RecastNavMesh in the world to the requested runtime-generation mode.
|
||||
* Static = baked nav only, no runtime updates (fastest gameplay).
|
||||
* DynamicModifiersOnly = only NavModifierVolumes affect nav at runtime.
|
||||
* Dynamic = all collision-affecting actors update the nav mesh on spawn / move.
|
||||
*
|
||||
* Idempotent — calling with the same mode the navmesh already has is a no-op.
|
||||
*/
|
||||
PS_EDITOR_API void SetRuntimeGenerationMode(UWorld* World, ERuntimeGenerationType Mode);
|
||||
|
||||
/** Convenience: switch to Dynamic if bDynamic, Static otherwise. */
|
||||
PS_EDITOR_API void SetDynamicNavigationEnabled(UWorld* World, bool bDynamic);
|
||||
|
||||
/**
|
||||
* Toggle the runtime navmesh visualization (mimics the editor's P key — green nav lines
|
||||
* + tile bounds rendered on top of the world). Affects all RecastNavMesh actors in the
|
||||
* world. Free in editor PIE/Standalone; in shipping packaged builds, debug rendering may
|
||||
* be stripped depending on build flags.
|
||||
*/
|
||||
PS_EDITOR_API void SetNavMeshVisible(UWorld* World, bool bVisible);
|
||||
|
||||
/**
|
||||
* Draw the navmesh as green debug lines for the current frame. Bypasses UE's own nav
|
||||
* debug renderer (which is gated behind UE_ALLOW_NAVMESH_DEBUG_DRAWING_IN_GAME and
|
||||
* doesn't work on binary engine installs at runtime). Pulls the geometry from each
|
||||
* ARecastNavMesh and emits DrawDebugLine calls — works in PIE / Standalone / packaged
|
||||
* shipping alike.
|
||||
*
|
||||
* Internally caches the FRecastDebugGeometry between calls and only refreshes it when
|
||||
* RefreshIntervalSeconds elapses, since GetDebugGeometry is expensive on big navmeshes.
|
||||
*
|
||||
* Call from a per-tick code path while the user wants the visualization shown; stop
|
||||
* calling when they hide it (DrawDebugLine entries auto-expire after 1 frame).
|
||||
*/
|
||||
PS_EDITOR_API void DrawNavMeshDebug(UWorld* World, float DeltaTime, float RefreshIntervalSeconds = 0.5f);
|
||||
|
||||
/** Drop the cached debug geometry (call when leaving editor mode / unloading scene). */
|
||||
PS_EDITOR_API void ClearNavMeshDebugCache();
|
||||
}
|
||||
@@ -42,7 +42,7 @@ void UPS_Editor_SpawnCatalog::CaptureAllThumbnails()
|
||||
// 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());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: %s already has a thumbnail, skipping"), *LoadedClass->GetName());
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ void UPS_Editor_SpawnCatalog::CaptureAllThumbnails()
|
||||
Blueprint->MarkPackageDirty();
|
||||
|
||||
Captured++;
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Captured thumbnail for %s -> %s"), *Blueprint->GetName(), *FullPath);
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Captured thumbnail for %s -> %s"), *Blueprint->GetName(), *FullPath);
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Captured %d thumbnails"), Captured);
|
||||
|
||||
@@ -14,7 +14,7 @@ struct FPS_Editor_SpawnEntry
|
||||
GENERATED_BODY()
|
||||
|
||||
/** The Blueprint class to spawn. Must have a PS_Editor_SpawnableComponent for name/category. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
TSubclassOf<AActor> ActorClass;
|
||||
};
|
||||
|
||||
@@ -34,12 +34,12 @@ class PS_EDITOR_API UPS_Editor_SpawnCatalog : public UPrimaryDataAsset
|
||||
|
||||
public:
|
||||
/** List of spawnable actors. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
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 = "ASTERION|PS_Editor")
|
||||
UFUNCTION(CallInEditor, Category = "PS_Editor")
|
||||
void CaptureAllThumbnails();
|
||||
#endif
|
||||
};
|
||||
@@ -55,18 +55,18 @@ struct FPS_Editor_BaseLevelEntry
|
||||
GENERATED_BODY()
|
||||
|
||||
/** Display name shown in the editor UI and used for scene filtering. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
FString DisplayName;
|
||||
|
||||
/**
|
||||
* Full map path used for OpenLevel and sublevel loading.
|
||||
* Example: "/Game/Maps/Warehouse"
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
FString MapPath;
|
||||
|
||||
/** Thumbnail image shown in the BaseLevel selection popup. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
TObjectPtr<UTexture2D> Thumbnail;
|
||||
|
||||
/**
|
||||
@@ -75,7 +75,7 @@ struct FPS_Editor_BaseLevelEntry
|
||||
* but not automatically loaded when the base level is a sublevel itself.
|
||||
* Full map paths, e.g. "/Game/Maps/Warehouse_Lighting"
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
TArray<FString> AdditionalSublevels;
|
||||
|
||||
/**
|
||||
@@ -83,7 +83,7 @@ struct FPS_Editor_BaseLevelEntry
|
||||
* Their entries are merged with the global catalogs (MasterCatalog::Catalogs).
|
||||
* Empty = only global catalogs are shown.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
TArray<TObjectPtr<UPS_Editor_SpawnCatalog>> Catalogs;
|
||||
|
||||
/**
|
||||
@@ -92,7 +92,7 @@ struct FPS_Editor_BaseLevelEntry
|
||||
* are, the first one wins; if none is, scenarios without a base level keep whatever
|
||||
* sublevel is currently loaded.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
bool bIsDefault = false;
|
||||
};
|
||||
|
||||
@@ -108,7 +108,7 @@ class PS_EDITOR_API UPS_Editor_MasterCatalog : public UPrimaryDataAsset
|
||||
|
||||
public:
|
||||
/** Sub-catalogs to load. Each one contributes its entries to the runtime spawn UI. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite)
|
||||
TArray<TObjectPtr<UPS_Editor_SpawnCatalog>> Catalogs;
|
||||
|
||||
/**
|
||||
@@ -116,6 +116,18 @@ public:
|
||||
* DisplayName is used for UI display and scene filtering.
|
||||
* MapPath is the full package path for OpenLevel (e.g. "/Game/Maps/Warehouse").
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_Editor")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS_Editor")
|
||||
TArray<FPS_Editor_BaseLevelEntry> BaseLevels;
|
||||
|
||||
/**
|
||||
* Optional project-specific enum used to categorize scenarios. The runtime editor's
|
||||
* "Scenario Type" dropdown (Settings popup) reads this enum to populate its choices.
|
||||
* The picked value is saved per-scenario as a uint8 (index into the enum) and exposed
|
||||
* at runtime via UPS_Editor_GameInstanceSubsystem::PendingScenarioType — the gameplay
|
||||
* GameMode reads it (Byte to Enum) and dispatches its scenario-type-specific logic.
|
||||
*
|
||||
* Leave null if your project doesn't use scenario typing — the dropdown is hidden.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS_Editor")
|
||||
TObjectPtr<UEnum> ScenarioTypeEnum;
|
||||
};
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
#include "PS_Editor_PlayerController.h"
|
||||
#include "PS_Editor_TimelineSubsystem.h"
|
||||
#include "PS_Editor_GroundSnap.h"
|
||||
#include "PS_Editor_NavUtils.h"
|
||||
#include "PS_Editor_MandatoryAnchor.h"
|
||||
#include "PS_Editor_FloorCutManager.h"
|
||||
#include "AIController.h"
|
||||
#include "BrainComponent.h"
|
||||
#include "EngineUtils.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "GameFramework/Character.h"
|
||||
#include "GameFramework/CharacterMovementComponent.h"
|
||||
@@ -91,11 +95,7 @@ void UPS_Editor_SpawnManager::AddCatalog(UPS_Editor_SpawnCatalog* InCatalog)
|
||||
|
||||
Resolved.Category = SpawnComp ? SpawnComp->Category : TEXT("Default");
|
||||
Resolved.Thumbnail = SpawnComp ? SpawnComp->Thumbnail : nullptr;
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolved '%s' - SpawnComp=%s, Thumbnail=%s"),
|
||||
*Resolved.DisplayName,
|
||||
SpawnComp ? TEXT("found") : TEXT("null"),
|
||||
Resolved.Thumbnail ? *Resolved.Thumbnail->GetPathName() : TEXT("null"));
|
||||
Resolved.bIsMandatory = SpawnComp ? SpawnComp->bIsMandatory : false;
|
||||
|
||||
ResolvedEntries.Add(Resolved);
|
||||
}
|
||||
@@ -130,7 +130,13 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector
|
||||
}
|
||||
|
||||
FActorSpawnParameters SpawnParams;
|
||||
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
|
||||
// AlwaysSpawn (instead of AdjustIfPossibleButAlwaysSpawn) so UE doesn't push the actor
|
||||
// upward when its bounds overlap the ground at spawn time — our SnapActorToGround call
|
||||
// below is the authoritative ground placement step, and it uses collision-only bounds
|
||||
// which correctly ignore non-collidable attached meshes (weapons, child actors).
|
||||
// With the old override, a character with a weapon dangling below the capsule could
|
||||
// be shoved high by the auto-adjust, leaving it stuck in the air.
|
||||
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
|
||||
|
||||
AActor* SpawnedActor = PC->GetWorld()->SpawnActor<AActor>(Entry.ActorClass, WorldLocation, FRotator::ZeroRotator, SpawnParams);
|
||||
if (!SpawnedActor)
|
||||
@@ -160,10 +166,13 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector
|
||||
SpawnedActors.Add(SpawnedActor);
|
||||
RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor);
|
||||
|
||||
// Auto-snap to ground if configured — uses the shared helper so it handles pivot
|
||||
// positions (characters with pivot at feet) + ignores the fresh actor itself.
|
||||
// Catalogue spawns are always a deliberate user placement, so always snap to ground.
|
||||
// Previously gated on SpawnableComponent::bAutoSnapToGround — that flag is preserved
|
||||
// as the gameplay-spawn hint (for SceneLoader / runtime spawning) but here we want
|
||||
// consistent placement regardless of BP configuration. If the actor genuinely shouldn't
|
||||
// touch the ground, the user can move it with the gizmo after spawning.
|
||||
UPS_Editor_SpawnableComponent* SpawnComp = SpawnedActor->FindComponentByClass<UPS_Editor_SpawnableComponent>();
|
||||
if (SpawnComp && SpawnComp->bAutoSnapToGround && PC->GetWorld())
|
||||
if (PC->GetWorld())
|
||||
{
|
||||
TArray<AActor*> ExtraIgnored;
|
||||
if (APawn* Pwn = PC->GetPawn()) ExtraIgnored.Add(Pwn);
|
||||
@@ -302,6 +311,19 @@ AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Lo
|
||||
SpawnedActors.Add(SpawnedActor);
|
||||
RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor);
|
||||
|
||||
// If the editor's floor-cut is active, evaluate the new actor against it so it doesn't
|
||||
// appear floating above a "hidden" floor.
|
||||
if (UWorld* W = PC->GetWorld())
|
||||
{
|
||||
if (UPS_Editor_FloorCutManager* FC = W->GetSubsystem<UPS_Editor_FloorCutManager>())
|
||||
{
|
||||
if (FC->IsEnabled())
|
||||
{
|
||||
FC->OnActorSpawned(SpawnedActor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SpawnedActor;
|
||||
}
|
||||
|
||||
@@ -327,6 +349,17 @@ void UPS_Editor_SpawnManager::TrackSpawnedActor(AActor* Actor)
|
||||
if (APlayerController* PC = OwnerPC.Get())
|
||||
{
|
||||
RegisterSpawnedActorId(PC->GetWorld(), Actor);
|
||||
// Re-evaluate against the floor cut (duplicate / paste paths land here).
|
||||
if (UWorld* W = PC->GetWorld())
|
||||
{
|
||||
if (UPS_Editor_FloorCutManager* FC = W->GetSubsystem<UPS_Editor_FloorCutManager>())
|
||||
{
|
||||
if (FC->IsEnabled())
|
||||
{
|
||||
FC->OnActorSpawned(Actor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,7 +447,6 @@ AActor* UPS_Editor_SpawnManager::RespawnActorWithProperties(AActor* OldActor, co
|
||||
if (Prop)
|
||||
{
|
||||
Prop->ImportText_InContainer(*Pair.Value, Target, Target, PPF_None);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Respawn apply [%s] = '%s' on %s"), *Pair.Key, *Pair.Value, *Target->GetName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,6 +492,111 @@ void UPS_Editor_SpawnManager::NotifyEditorModeChanged(bool bIsEditing)
|
||||
}
|
||||
}
|
||||
|
||||
bool UPS_Editor_SpawnManager::IsActorDeletable(const AActor* Actor) const
|
||||
{
|
||||
if (!Actor) return true;
|
||||
const UPS_Editor_SpawnableComponent* SpawnComp = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>();
|
||||
return !(SpawnComp && SpawnComp->bIsMandatory);
|
||||
}
|
||||
|
||||
void UPS_Editor_SpawnManager::EnsureMandatoryActorsPresent()
|
||||
{
|
||||
APlayerController* PC = OwnerPC.Get();
|
||||
UWorld* World = PC ? PC->GetWorld() : nullptr;
|
||||
if (!World) return;
|
||||
|
||||
// Diagnostic: count how many catalog entries are flagged mandatory. If zero, the user
|
||||
// likely hasn't restarted PIE since marking the BP — Live Coding doesn't re-resolve the
|
||||
// catalog, so cached bIsMandatory values stay stale. Surface this clearly in the log.
|
||||
int32 MandatoryCount = 0;
|
||||
for (const FPS_Editor_ResolvedEntry& E : ResolvedEntries)
|
||||
{
|
||||
if (E.bIsMandatory) ++MandatoryCount;
|
||||
}
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: EnsureMandatoryActorsPresent — scanning %d catalog entries (%d marked mandatory)"),
|
||||
ResolvedEntries.Num(), MandatoryCount);
|
||||
if (MandatoryCount == 0)
|
||||
{
|
||||
// Nothing to do. Don't bother locating an anchor.
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Locate the standalone APS_Editor_MandatoryAnchor in the loaded baseLevel. We take
|
||||
// the first one found. If absent, fall back to world origin (with a warning) so
|
||||
// mandatory actors still land somewhere reachable.
|
||||
FTransform AnchorXf = FTransform::Identity;
|
||||
bool bAnchorFound = false;
|
||||
for (TActorIterator<APS_Editor_MandatoryAnchor> It(World); It; ++It)
|
||||
{
|
||||
if (APS_Editor_MandatoryAnchor* Anchor = *It)
|
||||
{
|
||||
AnchorXf = Anchor->GetActorTransform();
|
||||
bAnchorFound = true;
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: MandatoryAnchor found at %s"), *AnchorXf.GetLocation().ToString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bAnchorFound)
|
||||
{
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: EnsureMandatoryActorsPresent — no APS_Editor_MandatoryAnchor placed in the baseLevel. Mandatory actors will spawn at world origin."));
|
||||
}
|
||||
|
||||
// 2. For each mandatory class in the catalog: skip if any tracked actor of that class
|
||||
// already exists, otherwise spawn one at the anchor transform. SpawnActorDirect routes
|
||||
// through the standard tracking path so the new actor lands in SpawnedActors and gets
|
||||
// a stable timeline ActorId.
|
||||
int32 SpawnedNow = 0;
|
||||
int32 SkippedExisting = 0;
|
||||
for (const FPS_Editor_ResolvedEntry& Entry : ResolvedEntries)
|
||||
{
|
||||
if (!Entry.bIsMandatory || !Entry.ActorClass) continue;
|
||||
|
||||
// Already present?
|
||||
bool bAlreadyPresent = false;
|
||||
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
|
||||
{
|
||||
AActor* Existing = Weak.Get();
|
||||
if (Existing && Existing->IsA(Entry.ActorClass))
|
||||
{
|
||||
bAlreadyPresent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bAlreadyPresent)
|
||||
{
|
||||
++SkippedExisting;
|
||||
continue;
|
||||
}
|
||||
|
||||
AActor* NewActor = SpawnActorDirect(Entry.ActorClass, AnchorXf.GetLocation(),
|
||||
AnchorXf.Rotator(), AnchorXf.GetScale3D());
|
||||
if (NewActor)
|
||||
{
|
||||
++SpawnedNow;
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Mandatory actor auto-spawned: %s at %s"),
|
||||
*Entry.DisplayName, *AnchorXf.GetLocation().ToString());
|
||||
|
||||
// Fire OnPropertiesLoaded so the actor's BP can run its init logic. For loaded
|
||||
// scenes, the SceneLoader/Serializer already fires this after applying JSON
|
||||
// properties; mandatory auto-spawn has no properties to apply, but the actor
|
||||
// still needs the same init hook (e.g. setting up visuals based on defaults).
|
||||
// Without this call, BP code that relies on OnPropertiesLoaded silently doesn't
|
||||
// run for auto-spawned actors → the actor stays in its raw post-BeginPlay state.
|
||||
if (NewActor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
|
||||
{
|
||||
IPS_Editor_EditableInterface::Execute_OnPropertiesLoaded(NewActor);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to spawn mandatory actor %s"), *Entry.DisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: EnsureMandatoryActorsPresent — done. Spawned=%d, AlreadyPresent=%d"),
|
||||
SpawnedNow, SkippedExisting);
|
||||
}
|
||||
|
||||
void UPS_Editor_SpawnManager::TickEditorMode(float DeltaTime)
|
||||
{
|
||||
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
|
||||
@@ -471,6 +608,24 @@ void UPS_Editor_SpawnManager::TickEditorMode(float DeltaTime)
|
||||
IPS_Editor_EditableInterface::Execute_OnEditorTick(Actor, DeltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom navmesh visualization (P key / Settings checkbox). Bypasses UE's nav debug
|
||||
// renderer (which is gated behind UE_ALLOW_NAVMESH_DEBUG_DRAWING_IN_GAME and unusable
|
||||
// on binary engine builds at runtime). Triangulates each ARecastNavMesh and emits
|
||||
// DrawDebugLine calls per edge — works in PIE / Standalone / packaged. The proc-mesh
|
||||
// surface overlay is also (re)built inside DrawNavMeshDebug; on the on→off transition
|
||||
// we explicitly clear the cache so the surface actor is destroyed (DrawDebugLine entries
|
||||
// auto-expire after 1 frame, but the proc-mesh actor needs an explicit teardown).
|
||||
if (bShowNavMesh)
|
||||
{
|
||||
PS_Editor_NavUtils::DrawNavMeshDebug(GetWorld(), DeltaTime);
|
||||
bWasShowingNavMesh = true;
|
||||
}
|
||||
else if (bWasShowingNavMesh)
|
||||
{
|
||||
PS_Editor_NavUtils::ClearNavMeshDebugCache();
|
||||
bWasShowingNavMesh = false;
|
||||
}
|
||||
}
|
||||
|
||||
TArray<FString> UPS_Editor_SpawnManager::GetCategories() const
|
||||
|
||||
@@ -24,6 +24,10 @@ struct FPS_Editor_ResolvedEntry
|
||||
|
||||
UPROPERTY()
|
||||
TObjectPtr<UTexture2D> Thumbnail;
|
||||
|
||||
/** Cached from UPS_Editor_SpawnableComponent::bIsMandatory at catalog resolution time.
|
||||
* Mandatory entries are auto-spawned on new-scene/load and hidden from the catalog UI. */
|
||||
bool bIsMandatory = false;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,6 +79,46 @@ public:
|
||||
/** Notify all spawned actors that implement IPS_Editor_EditableInterface of editor mode change. */
|
||||
void NotifyEditorModeChanged(bool bIsEditing);
|
||||
|
||||
/**
|
||||
* Walk the catalog, and for every class flagged bIsMandatory that has no instance currently
|
||||
* tracked in SpawnedActors, spawn one at the world's APS_Editor_CameraStart::MandatorySpawnAnchor
|
||||
* (or world origin if none found). Idempotent — calling repeatedly is safe; missing instances
|
||||
* are added, existing ones are left untouched.
|
||||
*
|
||||
* Called from SceneSerializer::ClearScene (new scene) and SceneLoader (after JSON load) so
|
||||
* mandatory actors are guaranteed present whether the user starts fresh or opens a legacy
|
||||
* save that predates the mandatory flag.
|
||||
*/
|
||||
void EnsureMandatoryActorsPresent();
|
||||
|
||||
/** Returns false if Actor has a UPS_Editor_SpawnableComponent with bIsMandatory==true.
|
||||
* Used by the delete code path (Pawn::HandleDelete) to skip mandatory actors silently. */
|
||||
bool IsActorDeletable(const AActor* Actor) const;
|
||||
|
||||
// ---- Per-scenario settings (persisted in JSON) ----
|
||||
|
||||
/** If true, switch the level's RecastNavMesh to Dynamic runtime generation when this
|
||||
* scenario is loaded — both in editor and gameplay. False = keep cooked Static nav.
|
||||
* Persisted in the scenario JSON; default false. */
|
||||
bool bUseDynamicNavigation = false;
|
||||
|
||||
/** Project-specific scenario type as uint8 (index into MasterCatalog::ScenarioTypeEnum).
|
||||
* Set via the Settings popup dropdown, persisted in the scenario JSON, pushed into
|
||||
* PS_Editor_GameInstanceSubsystem::PendingScenarioType at Play click for gameplay
|
||||
* GameMode to consume. Default 0 (first enum entry). */
|
||||
uint8 ScenarioType = 0;
|
||||
|
||||
// ---- Session-only UI preferences (NOT persisted) ----
|
||||
|
||||
/** If true, the navmesh is rendered at runtime (mimics the editor's P key). Personal
|
||||
* visualization preference, never saved with the scenario. */
|
||||
bool bShowNavMesh = false;
|
||||
|
||||
/** Tracks the previous tick's bShowNavMesh state so TickEditorMode can detect the
|
||||
* on→off transition and tear down the proc-mesh surface actor that DrawNavMeshDebug
|
||||
* spawned. Without this, the surface would stay visible after the user toggled it off. */
|
||||
bool bWasShowingNavMesh = false;
|
||||
|
||||
/** Tick all spawned actors that implement IPS_Editor_EditableInterface. */
|
||||
void TickEditorMode(float DeltaTime);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 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 = "ASTERION|PS Editor", meta = (BlueprintSpawnableComponent, DisplayName = "PS Editor Spawnable"))
|
||||
UCLASS(ClassGroup = "PS Editor", meta = (BlueprintSpawnableComponent, DisplayName = "PS Editor Spawnable"))
|
||||
class PS_EDITOR_API UPS_Editor_SpawnableComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
@@ -54,6 +54,15 @@ public:
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
|
||||
bool bPreload = false;
|
||||
|
||||
/** If true, this actor class is mandatory for any scenario:
|
||||
* - Auto-spawned on "New Scene" (one instance, at the MandatorySpawnAnchor of the
|
||||
* baseLevel's APS_Editor_CameraStart, falling back to world origin)
|
||||
* - On scene load, if missing, auto-added (handles legacy saves cleanly)
|
||||
* - Cannot be deleted by the user (delete is silently refused with a warning log)
|
||||
* - Hidden from the catalog (you can't add a 2nd instance) */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawnable")
|
||||
bool bIsMandatory = false;
|
||||
|
||||
// ---- Runtime-only: saved movement/rotation settings before editor override ----
|
||||
// Set when the editor disables AI/rotation overrides, restored on StartSimulation.
|
||||
bool bSavedMovementState = false;
|
||||
|
||||
@@ -31,7 +31,7 @@ public:
|
||||
virtual int32 GetCurrentPointCount() const override { return GetNumPoints(); }
|
||||
|
||||
/** Get the underlying USplineComponent (for AI path following, etc.). */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Spline")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
USplineComponent* GetSplineComponent() const { return SplineComp; }
|
||||
|
||||
/** Spline display color (override in Blueprint subclasses for variants). */
|
||||
@@ -75,7 +75,7 @@ public:
|
||||
virtual void UpdateVisualization() override;
|
||||
|
||||
/** Re-apply SplineColor to materials (call after changing SplineColor at runtime). */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Spline")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
virtual void UpdateSplineMaterials() override;
|
||||
|
||||
// ---- Handle interaction ----
|
||||
|
||||
@@ -194,20 +194,16 @@ void UPS_Editor_TimelineSubsystem::CaptureBaselinesForAllMissingTracks()
|
||||
|
||||
void UPS_Editor_TimelineSubsystem::RestoreBaseline()
|
||||
{
|
||||
if (PrePlayBaseline.Num() == 0) return;
|
||||
|
||||
// Server-only write path (same guard as ApplyValuesAtTime).
|
||||
if (UWorld* World = GetWorld())
|
||||
{
|
||||
if (World->GetNetMode() == NM_Client) return;
|
||||
}
|
||||
|
||||
const float ZeroEpsilon = 0.0001f;
|
||||
|
||||
for (const FPS_Editor_TimelineTrack& Track : Data.Tracks)
|
||||
{
|
||||
const TPair<FGuid, FName> Key(Track.ActorId, Track.PropertyPath);
|
||||
const FString* BaselineValue = PrePlayBaseline.Find(Key);
|
||||
if (!BaselineValue) continue;
|
||||
|
||||
AActor* Actor = FindActorById(Track.ActorId);
|
||||
if (!Actor) continue;
|
||||
UObject* Target = nullptr;
|
||||
@@ -215,7 +211,39 @@ void UPS_Editor_TimelineSubsystem::RestoreBaseline()
|
||||
ResolveTarget(Actor, Track.PropertyPath, Target, Prop);
|
||||
if (!Target || !Prop) continue;
|
||||
|
||||
Prop->ImportText_InContainer(**BaselineValue, Target, Target, PPF_None);
|
||||
// Pick the value to restore:
|
||||
// - If the user placed a key at (or before) t=0 → use that key's value. This
|
||||
// matches the natural mental model where a key at t=0 sets the timeline's
|
||||
// starting state — and lets the user adjust it just by re-keying at t=0
|
||||
// (otherwise Stop would keep snapping back to the captured pre-animation
|
||||
// baseline forever, ignoring their edits).
|
||||
// - Otherwise fall back to the captured pre-animation baseline so Stop still
|
||||
// undoes whatever the timeline pushed (relevant for tracks whose first key
|
||||
// is past t=0, e.g. a property that only changes mid-timeline).
|
||||
const FString* RestoreValue = nullptr;
|
||||
FString KeyZeroValue;
|
||||
for (const FPS_Editor_TimelineKey& K : Track.Keys)
|
||||
{
|
||||
if (K.Time <= ZeroEpsilon)
|
||||
{
|
||||
KeyZeroValue = K.Value;
|
||||
RestoreValue = &KeyZeroValue;
|
||||
// Continue scanning in case there are several keys at t≈0 — the LAST one wins
|
||||
// (matches sort order: later same-time entries override earlier ones).
|
||||
}
|
||||
else
|
||||
{
|
||||
break; // keys are sorted by time; no need to look past t>0
|
||||
}
|
||||
}
|
||||
if (!RestoreValue)
|
||||
{
|
||||
const TPair<FGuid, FName> BaselineKey(Track.ActorId, Track.PropertyPath);
|
||||
RestoreValue = PrePlayBaseline.Find(BaselineKey);
|
||||
}
|
||||
if (!RestoreValue) continue;
|
||||
|
||||
Prop->ImportText_InContainer(**RestoreValue, Target, Target, PPF_None);
|
||||
if (UActorComponent* AsComp = Cast<UActorComponent>(Target))
|
||||
{
|
||||
AsComp->MarkRenderStateDirty();
|
||||
@@ -234,15 +262,40 @@ void UPS_Editor_TimelineSubsystem::RestoreBaseline()
|
||||
}
|
||||
}
|
||||
|
||||
// Don't clear the map — a subsequent Stop (or Restart) must still restore the same baseline.
|
||||
// Don't clear PrePlayBaseline — subsequent Stops without a t=0 key still need it.
|
||||
// SetData/Clear are the only resetters.
|
||||
}
|
||||
|
||||
void UPS_Editor_TimelineSubsystem::Restart()
|
||||
{
|
||||
// Rewind + replay from the start. Done in two steps so simulation transforms are restored
|
||||
// by Stop() before Play() re-enables AI, giving a clean "from the beginning" rerun.
|
||||
Stop();
|
||||
// Rewind + replay from the start.
|
||||
//
|
||||
// We can't just call Stop() then Play() because Stop() goes through
|
||||
// PlayerController::RequestStopSimulation which DEFERS the actual StopSimulation by
|
||||
// 500ms (BT grace period). The Play() call right after cancels that pending timer
|
||||
// AND re-enters StartSimulation early (bSimulating still true), so transforms are
|
||||
// never restored — characters stay at their current position instead of rewinding.
|
||||
//
|
||||
// Fix: do the full reset synchronously here. Fire OnEditorBeforeSimulateStop (via
|
||||
// RequestStopSimulation(bImmediate=true) so BP behaviour-reset hooks still run),
|
||||
// then immediately StopSimulation (which restores transforms + fires
|
||||
// OnEditorSimulateStop), then Play() to capture the rewound transforms and restart
|
||||
// AI from the clean state.
|
||||
bIsPlaying = false;
|
||||
CurrentTime = 0.f;
|
||||
LastAppliedValues.Empty();
|
||||
LastAppliedValues.SetNum(Data.Tracks.Num());
|
||||
|
||||
RestoreBaseline();
|
||||
|
||||
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
|
||||
{
|
||||
if (EditorPC->IsSimulating())
|
||||
{
|
||||
EditorPC->RequestStopSimulation(/*bImmediate=*/ true);
|
||||
}
|
||||
}
|
||||
|
||||
Play();
|
||||
}
|
||||
|
||||
@@ -321,6 +374,14 @@ static int32 FindTrack(const FPS_Editor_TimelineData& Data, const FGuid& ActorId
|
||||
return INDEX_NONE;
|
||||
}
|
||||
|
||||
bool UPS_Editor_TimelineSubsystem::IsPropertyTracked(AActor* Actor, const FName& PropertyPath) const
|
||||
{
|
||||
if (!Actor) return false;
|
||||
const FGuid ActorId = FindActorId(Actor);
|
||||
if (!ActorId.IsValid()) return false;
|
||||
return FindTrack(Data, ActorId, PropertyPath) != INDEX_NONE;
|
||||
}
|
||||
|
||||
bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, const FName& PropertyPath)
|
||||
{
|
||||
if (!Actor) return false;
|
||||
@@ -328,7 +389,7 @@ bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, co
|
||||
const FGuid ActorId = GetOrCreateActorId(Actor);
|
||||
if (!ActorId.IsValid()) return false;
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor Timeline: AddOrUpdateKey — Actor=%s ActorId=%s Prop=%s CurrentTime=%.3f"),
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor Timeline: AddOrUpdateKey — Actor=%s ActorId=%s Prop=%s CurrentTime=%.3f"),
|
||||
*Actor->GetName(), *ActorId.ToString(), *PropertyPath.ToString(), CurrentTime);
|
||||
|
||||
// Resolve property to read current value
|
||||
@@ -364,6 +425,14 @@ bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, co
|
||||
if (FMath::IsNearlyEqual(Track.Keys[InsertAt].Time, CurrentTime, Epsilon))
|
||||
{
|
||||
Track.Keys[InsertAt].Value = CurrentValue;
|
||||
// Invalidate the per-track applied-value cache so the next ApplyTrackAtTime
|
||||
// re-writes the (possibly different) value instead of skipping based on the
|
||||
// stale cache entry. Otherwise, updating a key with a value the cache happened
|
||||
// to hold from a previous apply would silently revert the property to it.
|
||||
if (LastAppliedValues.IsValidIndex(TrackIndex))
|
||||
{
|
||||
LastAppliedValues[TrackIndex].Empty();
|
||||
}
|
||||
OnTimelineChanged.Broadcast();
|
||||
return true;
|
||||
}
|
||||
@@ -404,6 +473,12 @@ void UPS_Editor_TimelineSubsystem::RemoveKey(int32 TrackIndex, int32 KeyIndex)
|
||||
Data.Tracks.RemoveAt(TrackIndex);
|
||||
LastAppliedValues.SetNum(Data.Tracks.Num());
|
||||
}
|
||||
else if (LastAppliedValues.IsValidIndex(TrackIndex))
|
||||
{
|
||||
// Track still has keys but its surrounding keys for any time may have changed —
|
||||
// invalidate the cache so the next apply re-writes the correct value.
|
||||
LastAppliedValues[TrackIndex].Empty();
|
||||
}
|
||||
OnTimelineChanged.Broadcast();
|
||||
}
|
||||
|
||||
@@ -413,6 +488,10 @@ void UPS_Editor_TimelineSubsystem::SetKeyInterpolate(int32 TrackIndex, int32 Key
|
||||
FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex];
|
||||
if (!Track.Keys.IsValidIndex(KeyIndex)) return;
|
||||
Track.Keys[KeyIndex].bInterpolate = bInterpolate;
|
||||
if (LastAppliedValues.IsValidIndex(TrackIndex))
|
||||
{
|
||||
LastAppliedValues[TrackIndex].Empty();
|
||||
}
|
||||
OnTimelineChanged.Broadcast();
|
||||
}
|
||||
|
||||
@@ -428,6 +507,10 @@ void UPS_Editor_TimelineSubsystem::SetKeyTime(int32 TrackIndex, int32 KeyIndex,
|
||||
{
|
||||
return A.Time < B.Time;
|
||||
});
|
||||
if (LastAppliedValues.IsValidIndex(TrackIndex))
|
||||
{
|
||||
LastAppliedValues[TrackIndex].Empty();
|
||||
}
|
||||
OnTimelineChanged.Broadcast();
|
||||
}
|
||||
|
||||
|
||||
@@ -46,39 +46,39 @@ public:
|
||||
// ---- Playback controls ----
|
||||
|
||||
/** Start / resume playback from CurrentTime. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void Play();
|
||||
|
||||
/** Pause playback, keeping CurrentTime. Simulation keeps running. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void Pause();
|
||||
|
||||
/** Pause playback, rewind to CurrentTime=0, stop simulation and apply t=0 values. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void Stop();
|
||||
|
||||
/** Rewind to t=0 and immediately replay from the start (re-enables simulation). */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void Restart();
|
||||
|
||||
/** Jump to a specific time (seconds, clamped to [0, Duration]). Applies values immediately. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void Seek(float Time);
|
||||
|
||||
/** True while Tick is advancing CurrentTime. */
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor|Timeline")
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS_Editor")
|
||||
bool IsPlaying() const { return bIsPlaying; }
|
||||
|
||||
/** Get current playback time (seconds). */
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor|Timeline")
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS_Editor")
|
||||
float GetCurrentTime() const { return CurrentTime; }
|
||||
|
||||
/** Get the configured duration (seconds). */
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|PS_Editor|Timeline")
|
||||
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS_Editor")
|
||||
float GetDuration() const { return Data.Duration; }
|
||||
|
||||
/** Change the timeline duration. Values <= 0 are clamped to 0.1. */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|Timeline")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
void SetDuration(float NewDuration);
|
||||
|
||||
// ---- Tracks / keys ----
|
||||
@@ -88,6 +88,11 @@ public:
|
||||
|
||||
const TArray<FPS_Editor_TimelineTrack>& GetTracks() const { return Data.Tracks; }
|
||||
|
||||
/** Returns true if the timeline holds a track for (Actor, PropertyPath). Used by the
|
||||
* PlayerController's editable-property snapshot/restore so we don't double-restore
|
||||
* properties already handled by the timeline's RestoreBaseline. */
|
||||
bool IsPropertyTracked(AActor* Actor, const FName& PropertyPath) const;
|
||||
|
||||
/**
|
||||
* Add a key at CurrentTime for (Actor, PropertyPath), reading the actor's current value.
|
||||
* Creates the track if it doesn't exist. If a key already exists at this time, updates its value.
|
||||
|
||||
@@ -35,16 +35,16 @@ class PS_EDITOR_API APS_Editor_HUD : public AHUD
|
||||
|
||||
public:
|
||||
/** Which UI implementation to instantiate. Set via Details panel on the HUD Blueprint. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "ASTERION|PS_Editor|UI")
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "PS_Editor")
|
||||
EPS_Editor_UIMode UIMode = EPS_Editor_UIMode::NewSlate;
|
||||
|
||||
/** UMG Widget Blueprint class to spawn when UIMode == UMG.
|
||||
* Must derive from UPS_Editor_MainWidget_UMG. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "ASTERION|PS_Editor|UI", meta = (EditCondition = "UIMode == EPS_Editor_UIMode::UMG"))
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "PS_Editor", meta = (EditCondition = "UIMode == EPS_Editor_UIMode::UMG"))
|
||||
TSoftClassPtr<UPS_Editor_MainWidget_UMG> UMGWidgetClass;
|
||||
|
||||
/** Returns the active main editor widget (cast as needed). */
|
||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor|UI")
|
||||
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
|
||||
UUserWidget* GetMainWidget() const { return MainWidget; }
|
||||
|
||||
/** Backward-compat accessor for the refactored Slate widget (returns nullptr in Legacy/UMG modes). */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "PS_Editor_MainWidget.h"
|
||||
#include "PS_Editor_SelectionManager.h"
|
||||
#include "PS_Editor_SpawnManager.h"
|
||||
#include "PS_Editor_NavUtils.h"
|
||||
#include "PS_Editor_SceneSerializer.h"
|
||||
#include "PS_Editor_SceneLoader.h"
|
||||
#include "PS_Editor_UndoManager.h"
|
||||
@@ -31,6 +32,7 @@
|
||||
#include "DesktopPlatformModule.h"
|
||||
#include "PS_Editor_GameInstanceSubsystem.h"
|
||||
#include "PS_Editor_TimelineSubsystem.h"
|
||||
#include "PS_Editor_FloorCutManager.h"
|
||||
#include "Engine/World.h"
|
||||
#include "PS_Editor_UIStyleAsset.h"
|
||||
#include "PS_Editor_UIStyle.h"
|
||||
@@ -500,6 +502,173 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
|
||||
]
|
||||
]
|
||||
]
|
||||
// Settings popup (per-scenario nav option + session navmesh visu)
|
||||
+ SOverlay::Slot()
|
||||
.HAlign(HAlign_Center)
|
||||
.VAlign(VAlign_Center)
|
||||
[
|
||||
SAssignNew(SettingsPopup, SBorder)
|
||||
.Visibility(EVisibility::Collapsed)
|
||||
.BorderImage(BrushGlassStrong.Get())
|
||||
.Padding(FMargin(28.0f, 20.0f))
|
||||
[
|
||||
SNew(SVerticalBox)
|
||||
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 0.0f, 0.0f, 16.0f)
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Settings")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 14))
|
||||
.ColorAndOpacity(FLinearColor::White)
|
||||
]
|
||||
+ SVerticalBox::Slot().AutoHeight().Padding(0.0f, 4.0f)
|
||||
[
|
||||
SNew(SHorizontalBox)
|
||||
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0.0f, 0.0f, 8.0f, 0.0f)
|
||||
[
|
||||
SNew(SCheckBox)
|
||||
.IsChecked_Lambda([this]() -> ECheckBoxState
|
||||
{
|
||||
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
|
||||
{
|
||||
return SM->bUseDynamicNavigation ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
|
||||
}
|
||||
return ECheckBoxState::Unchecked;
|
||||
})
|
||||
.OnCheckStateChanged_Lambda([this](ECheckBoxState State) { OnUseDynamicNavigationToggled(State); })
|
||||
]
|
||||
+ SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center)
|
||||
[
|
||||
SNew(SVerticalBox)
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Use Dynamic Navigation")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
|
||||
.ColorAndOpacity(FLinearColor::White)
|
||||
]
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Rebuild navmesh at runtime when actors are spawned/moved (saved with scenario).")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
|
||||
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
|
||||
.AutoWrapText(true)
|
||||
]
|
||||
]
|
||||
]
|
||||
+ SVerticalBox::Slot().AutoHeight().Padding(0.0f, 8.0f, 0.0f, 4.0f)
|
||||
[
|
||||
SNew(SHorizontalBox)
|
||||
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0.0f, 0.0f, 8.0f, 0.0f)
|
||||
[
|
||||
SNew(SCheckBox)
|
||||
.IsChecked_Lambda([this]() -> ECheckBoxState
|
||||
{
|
||||
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
|
||||
{
|
||||
return SM->bShowNavMesh ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
|
||||
}
|
||||
return ECheckBoxState::Unchecked;
|
||||
})
|
||||
.OnCheckStateChanged_Lambda([this](ECheckBoxState State) { OnShowNavMeshToggled(State); })
|
||||
]
|
||||
+ SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center)
|
||||
[
|
||||
SNew(SVerticalBox)
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Show NavMesh (P)")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
|
||||
.ColorAndOpacity(FLinearColor::White)
|
||||
]
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Render the navmesh in green (mimics editor's P key). Personal preference, never saved.")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
|
||||
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
|
||||
.AutoWrapText(true)
|
||||
]
|
||||
]
|
||||
]
|
||||
// Scenario Type dropdown — visible only when MasterCatalog::ScenarioTypeEnum is set.
|
||||
+ SVerticalBox::Slot().AutoHeight().Padding(0.0f, 8.0f, 0.0f, 4.0f)
|
||||
[
|
||||
SNew(SHorizontalBox)
|
||||
.Visibility_Lambda([this]() -> EVisibility
|
||||
{
|
||||
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
||||
{
|
||||
if (PC->MasterCatalogAsset && PC->MasterCatalogAsset->ScenarioTypeEnum)
|
||||
{
|
||||
return EVisibility::Visible;
|
||||
}
|
||||
}
|
||||
return EVisibility::Collapsed;
|
||||
})
|
||||
+ SHorizontalBox::Slot().FillWidth(0.45f).VAlign(VAlign_Center)
|
||||
[
|
||||
SNew(SVerticalBox)
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Scenario Type")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
|
||||
.ColorAndOpacity(FLinearColor::White)
|
||||
]
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Saved per-scenario; pushed to PendingScenarioType at Play.")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
|
||||
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
|
||||
.AutoWrapText(true)
|
||||
]
|
||||
]
|
||||
+ SHorizontalBox::Slot().FillWidth(0.55f).VAlign(VAlign_Center).Padding(8.0f, 0.0f, 0.0f, 0.0f)
|
||||
[
|
||||
SAssignNew(ScenarioTypeCombo, SComboBox<TSharedPtr<FString>>)
|
||||
.OptionsSource(&ScenarioTypeOptions)
|
||||
.OnComboBoxOpening_Lambda([this]() { RefreshScenarioTypeOptions(); })
|
||||
.OnGenerateWidget_Lambda([](TSharedPtr<FString> Item) -> TSharedRef<SWidget>
|
||||
{
|
||||
return SNew(STextBlock).Text(FText::FromString(Item.IsValid() ? *Item : FString()));
|
||||
})
|
||||
.OnSelectionChanged_Lambda([this](TSharedPtr<FString> NewItem, ESelectInfo::Type SelectType)
|
||||
{
|
||||
if (!NewItem.IsValid()) return;
|
||||
const int32 Idx = ScenarioTypeOptions.IndexOfByKey(NewItem);
|
||||
if (Idx == INDEX_NONE) return;
|
||||
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
|
||||
{
|
||||
SM->ScenarioType = (uint8)Idx;
|
||||
bSceneDirty = true;
|
||||
}
|
||||
})
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text_Lambda([this]() -> FText
|
||||
{
|
||||
return FText::FromString(GetCurrentScenarioTypeDisplayName());
|
||||
})
|
||||
]
|
||||
]
|
||||
]
|
||||
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 16.0f, 0.0f, 0.0f)
|
||||
[
|
||||
SNew(SButton)
|
||||
.ButtonStyle(StyleFlatButton.Get())
|
||||
.OnClicked_Lambda([this]() -> FReply { CloseSettingsPopup(); return FReply::Handled(); })
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Close")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
|
||||
.ColorAndOpacity(FLinearColor::White)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
// ============= COMMAND PALETTE (modal, Ctrl+K) =============
|
||||
+ SOverlay::Slot()
|
||||
@@ -724,6 +893,70 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildToolbar()
|
||||
})
|
||||
]
|
||||
]
|
||||
+ SHorizontalBox::Slot().AutoWidth() [ MakeSeparator() ]
|
||||
// ---- Floor cut: editor-only horizontal plane to hide upper floors ----
|
||||
+ SHorizontalBox::Slot().AutoWidth()
|
||||
[
|
||||
MakeToggleButton(EPS_Editor_Icon::Grid, EPS_Editor_Icon::Grid,
|
||||
TAttribute<FString>::CreateLambda([this]() -> FString
|
||||
{
|
||||
UWorld* W = GetWorld();
|
||||
UPS_Editor_FloorCutManager* FC = W ? W->GetSubsystem<UPS_Editor_FloorCutManager>() : nullptr;
|
||||
return (FC && FC->IsEnabled()) ? TEXT("Floor cut On") : TEXT("Floor cut");
|
||||
}),
|
||||
[this]() -> bool
|
||||
{
|
||||
UWorld* W = GetWorld();
|
||||
UPS_Editor_FloorCutManager* FC = W ? W->GetSubsystem<UPS_Editor_FloorCutManager>() : nullptr;
|
||||
return FC && FC->IsEnabled();
|
||||
},
|
||||
[this]()
|
||||
{
|
||||
if (UWorld* W = GetWorld())
|
||||
{
|
||||
if (UPS_Editor_FloorCutManager* FC = W->GetSubsystem<UPS_Editor_FloorCutManager>())
|
||||
{
|
||||
FC->SetEnabled(!FC->IsEnabled());
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(4.0f, 0.0f)
|
||||
[
|
||||
SNew(SBox).MinDesiredWidth(120.0f)
|
||||
[
|
||||
SAssignNew(FloorCutSlider, SSlider)
|
||||
.MinValue(0.0f)
|
||||
.MaxValue(1000.0f)
|
||||
.StepSize(10.0f)
|
||||
.MouseUsesStep(true)
|
||||
.Value(200.0f)
|
||||
.ToolTipText(FText::FromString(TEXT("Cut height above MandatoryAnchor (cm) — range 0..1000, step 10")))
|
||||
.OnValueChanged_Lambda([this](float NewValue)
|
||||
{
|
||||
const float Snapped = FMath::RoundToFloat(NewValue / 10.f) * 10.f;
|
||||
if (UWorld* W = GetWorld())
|
||||
{
|
||||
if (UPS_Editor_FloorCutManager* FC = W->GetSubsystem<UPS_Editor_FloorCutManager>())
|
||||
{
|
||||
FC->SetHeightAboveAnchor(Snapped);
|
||||
}
|
||||
}
|
||||
if (FloorCutValueText.IsValid())
|
||||
{
|
||||
FloorCutValueText->SetText(FText::FromString(FString::Printf(TEXT("%.0f cm"), Snapped)));
|
||||
}
|
||||
})
|
||||
]
|
||||
]
|
||||
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(2.0f, 0.0f)
|
||||
[
|
||||
SAssignNew(FloorCutValueText, STextBlock)
|
||||
.Text(FText::FromString(TEXT("200 cm")))
|
||||
.Font(FPS_Editor_UIStyle::SmallFont(S))
|
||||
.ColorAndOpacity(FPS_Editor_UIStyle::TextMuted(S))
|
||||
.MinDesiredWidth(48.0f)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@@ -976,6 +1209,10 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
|
||||
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
|
||||
{
|
||||
SceneSerializer->ClearScene(SpawnManager.Get());
|
||||
// Re-spawn mandatory actors immediately after the wipe — this is
|
||||
// the "New Scenario" path, where the user expects a fresh-but-not-empty
|
||||
// scene (mandatory class instances at the MandatoryAnchor).
|
||||
SpawnManager->EnsureMandatoryActorsPresent();
|
||||
bSceneDirty = false;
|
||||
LastKnownSpawnedCount = 0;
|
||||
}
|
||||
@@ -987,6 +1224,26 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
|
||||
]
|
||||
]
|
||||
// Settings button
|
||||
+ SHorizontalBox::Slot()
|
||||
.AutoWidth()
|
||||
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
|
||||
[
|
||||
SNew(SButton)
|
||||
.ButtonStyle(StyleFlatButton.Get())
|
||||
.ToolTipText(FText::FromString(TEXT("Open scenario / session settings (dynamic nav, navmesh visu).")))
|
||||
.OnClicked_Lambda([this]() -> FReply
|
||||
{
|
||||
OpenSettingsPopup();
|
||||
return FReply::Handled();
|
||||
})
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Settings")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
|
||||
.ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f))
|
||||
]
|
||||
]
|
||||
+ SHorizontalBox::Slot()
|
||||
.AutoWidth()
|
||||
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
|
||||
@@ -1041,7 +1298,8 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
|
||||
{
|
||||
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
|
||||
{
|
||||
Sub->SetPendingScenario(SceneName, BaseLevel);
|
||||
const uint8 TypeToSend = SpawnManager.IsValid() ? SpawnManager->ScenarioType : 0;
|
||||
Sub->SetPendingScenario(SceneName, BaseLevel, TypeToSend);
|
||||
Sub->bIsPlayMode = true;
|
||||
if (Sub->EditorMapName.IsEmpty())
|
||||
{
|
||||
@@ -1159,7 +1417,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
|
||||
}
|
||||
}
|
||||
|
||||
SceneSerializer->LoadScene(FileName, SpawnManager.Get());
|
||||
SceneSerializer->LoadScenario(FileName, SpawnManager.Get());
|
||||
bSceneDirty = false;
|
||||
if (SaveNameField.IsValid())
|
||||
{
|
||||
@@ -1418,18 +1676,14 @@ void UPS_Editor_MainWidget::PopulateSceneList()
|
||||
// No base level in the scenario file — try to resolve the default from the catalog.
|
||||
if (!bHasValidBaseLevel && EditorPC->MasterCatalogAsset)
|
||||
{
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scenario '%s' has no base level, searching for default in MasterCatalog (%d entries)"),
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Scenario '%s' has no base level, searching for default in MasterCatalog (%d entries)"),
|
||||
*Name, EditorPC->MasterCatalogAsset->BaseLevels.Num());
|
||||
for (const FPS_Editor_BaseLevelEntry& Entry : EditorPC->MasterCatalogAsset->BaseLevels)
|
||||
{
|
||||
UE_LOG(LogTemp, Log, TEXT(" BaseLevel entry: DisplayName='%s', MapPath='%s', bIsDefault=%d"),
|
||||
*Entry.DisplayName, *Entry.MapPath, Entry.bIsDefault ? 1 : 0);
|
||||
if (Entry.bIsDefault && !Entry.DisplayName.IsEmpty())
|
||||
{
|
||||
SceneLevelName = Entry.DisplayName;
|
||||
bHasValidBaseLevel = BaseLevelPathMap.Contains(SceneLevelName);
|
||||
UE_LOG(LogTemp, Log, TEXT(" -> Picked default '%s' (in PathMap=%d)"),
|
||||
*SceneLevelName, bHasValidBaseLevel ? 1 : 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1464,7 +1718,7 @@ void UPS_Editor_MainWidget::PopulateSceneList()
|
||||
// current sublevel as-is — don't unload it (avoids empty scenes).
|
||||
}
|
||||
|
||||
SceneSerializer->LoadScene(Name, SpawnManager.Get());
|
||||
SceneSerializer->LoadScenario(Name, SpawnManager.Get());
|
||||
bSceneDirty = false;
|
||||
if (SaveNameField.IsValid())
|
||||
{
|
||||
@@ -1548,6 +1802,10 @@ void UPS_Editor_MainWidget::PopulateSpawnCatalog()
|
||||
{
|
||||
const FPS_Editor_ResolvedEntry& Entry = Entries[i];
|
||||
|
||||
// Mandatory classes are auto-spawned and singletons — hide them from the catalog so
|
||||
// the user can't add a 2nd instance.
|
||||
if (Entry.bIsMandatory) continue;
|
||||
|
||||
// Category header
|
||||
if (Entry.Category != CurrentCategory)
|
||||
{
|
||||
@@ -1821,9 +2079,33 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
|
||||
bPointSelected = (Pawn->ActiveSplinePointIndex >= 0);
|
||||
}
|
||||
|
||||
// SplinePlacement mode is shared between IPS_Editor_SplineEditable and
|
||||
// IPS_Editor_ChildMovable — show ChildMovable-specific help when the active
|
||||
// placement actor implements the child interface (no point insertion, no Del,
|
||||
// rotation gizmo available via R key).
|
||||
bool bChildMovableActive = false;
|
||||
if (APS_Editor_PlayerController* EditorPC2 = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
||||
{
|
||||
if (AActor* ActiveActor = EditorPC2->GetActivePlacementActor())
|
||||
{
|
||||
bChildMovableActive = ActiveActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass());
|
||||
}
|
||||
}
|
||||
|
||||
if (HelpBarText.IsValid())
|
||||
{
|
||||
if (bPointSelected)
|
||||
if (bChildMovableActive)
|
||||
{
|
||||
if (bPointSelected)
|
||||
{
|
||||
HelpBarText->SetText(FText::FromString(TEXT("CHILDREN: Drag gizmo to move | R: rotate | T: translate | Esc/Done: finish")));
|
||||
}
|
||||
else
|
||||
{
|
||||
HelpBarText->SetText(FText::FromString(TEXT("CHILDREN: Click a child to select | Esc/Done: finish")));
|
||||
}
|
||||
}
|
||||
else if (bPointSelected)
|
||||
{
|
||||
HelpBarText->SetText(FText::FromString(TEXT("SPLINE: Drag gizmo to move | Del: delete point | Click line: insert | Click outside: add | Esc/Done: finish")));
|
||||
}
|
||||
@@ -1835,7 +2117,8 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
|
||||
}
|
||||
if (SplineEditButton.IsValid()) SplineEditButton->SetVisibility(EVisibility::Collapsed);
|
||||
if (ChildEditButton.IsValid()) ChildEditButton->SetVisibility(EVisibility::Collapsed);
|
||||
if (SplineDeletePointButton.IsValid()) SplineDeletePointButton->SetVisibility(bPointSelected ? EVisibility::Visible : EVisibility::Collapsed);
|
||||
// Delete-point button is meaningless for ChildMovable (fixed child set)
|
||||
if (SplineDeletePointButton.IsValid()) SplineDeletePointButton->SetVisibility((bPointSelected && !bChildMovableActive) ? EVisibility::Visible : EVisibility::Collapsed);
|
||||
if (SplineFinishButton.IsValid()) SplineFinishButton->SetVisibility(EVisibility::Visible);
|
||||
}
|
||||
else if (bChildMovableSelected)
|
||||
@@ -2243,7 +2526,7 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: EditableComponent has 0 properties on %s"), *Actor->GetName());
|
||||
return;
|
||||
}
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Building %d dynamic property rows for %s"), EditComp->EditableProperties.Num(), *Actor->GetName());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Building %d dynamic property rows for %s"), EditComp->EditableProperties.Num(), *Actor->GetName());
|
||||
|
||||
const FSlateFontInfo LabelFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
|
||||
const FSlateFontInfo ValueFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
|
||||
@@ -2271,8 +2554,6 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
|
||||
Row.DisplayName = EditComp->GetDisplayName(Entry.Path);
|
||||
Row.Key = Entry.Path.ToString();
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolving [%s] comp='%s' prop='%s'"), *Row.Key, *Row.ComponentName, *Row.PropertyName);
|
||||
|
||||
UObject* Target = FindPropertyTarget(Actor, Row.ComponentName);
|
||||
if (!Target)
|
||||
{
|
||||
@@ -2293,8 +2574,6 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Property '%s' not found on %s (%s)"), *Row.PropertyName, *Target->GetName(), *Target->GetClass()->GetName());
|
||||
continue;
|
||||
}
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolved OK → %s on %s"), *Row.CachedProperty->GetName(), *Target->GetClass()->GetName());
|
||||
|
||||
const int32 RowIndex = DynamicPropertyRows.Num();
|
||||
|
||||
// Label
|
||||
@@ -2545,7 +2824,7 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
|
||||
DynamicPropertyRows.Add(Row);
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Built %d dynamic property row(s)"), DynamicPropertyRows.Num());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Built %d dynamic property row(s)"), DynamicPropertyRows.Num());
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
|
||||
@@ -2875,7 +3154,7 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
|
||||
if (UFunction* RepFunc = Target->FindFunction(Row.CachedProperty->RepNotifyFunc))
|
||||
{
|
||||
Target->ProcessEvent(RepFunc, nullptr);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Called %s on %s"), *Row.CachedProperty->RepNotifyFunc.ToString(), *Target->GetName());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Called %s on %s"), *Row.CachedProperty->RepNotifyFunc.ToString(), *Target->GetName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2902,7 +3181,7 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Property %s changed: %s → %s"), *Row.Key, *OldValue, *NewValue);
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Property %s changed: %s → %s"), *Row.Key, *OldValue, *NewValue);
|
||||
|
||||
// Always notify property changed
|
||||
const bool bImplementsInterface = Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass());
|
||||
@@ -3182,6 +3461,90 @@ void UPS_Editor_MainWidget::CancelBaseLevelPopup()
|
||||
PendingBaseLevelSelection.Empty();
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget::OpenSettingsPopup()
|
||||
{
|
||||
if (SettingsPopup.IsValid())
|
||||
{
|
||||
// Refresh enum-driven options each time the popup opens — covers the case where the
|
||||
// MasterCatalogAsset (or its ScenarioTypeEnum) wasn't set at widget Construct time.
|
||||
RefreshScenarioTypeOptions();
|
||||
SettingsPopup->SetVisibility(EVisibility::Visible);
|
||||
}
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget::RefreshScenarioTypeOptions()
|
||||
{
|
||||
ScenarioTypeOptions.Empty();
|
||||
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
||||
{
|
||||
if (PC->MasterCatalogAsset && PC->MasterCatalogAsset->ScenarioTypeEnum)
|
||||
{
|
||||
UEnum* E = PC->MasterCatalogAsset->ScenarioTypeEnum;
|
||||
// NumEnums()-1 to skip the auto-generated _MAX entry on regular UENUMs.
|
||||
// User-defined enums (UUserDefinedEnum) don't have _MAX but the last "MAX" entry
|
||||
// is hidden behind the `bIsHidden` meta — we drop the last one defensively.
|
||||
const int32 Count = FMath::Max(0, E->NumEnums() - 1);
|
||||
for (int32 i = 0; i < Count; ++i)
|
||||
{
|
||||
ScenarioTypeOptions.Add(MakeShared<FString>(E->GetDisplayNameTextByIndex(i).ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ScenarioTypeCombo.IsValid())
|
||||
{
|
||||
ScenarioTypeCombo->RefreshOptions();
|
||||
}
|
||||
}
|
||||
|
||||
FString UPS_Editor_MainWidget::GetCurrentScenarioTypeDisplayName() const
|
||||
{
|
||||
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
|
||||
{
|
||||
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
||||
{
|
||||
if (PC->MasterCatalogAsset && PC->MasterCatalogAsset->ScenarioTypeEnum)
|
||||
{
|
||||
UEnum* E = PC->MasterCatalogAsset->ScenarioTypeEnum;
|
||||
const int32 Count = FMath::Max(0, E->NumEnums() - 1);
|
||||
if (SM->ScenarioType < Count)
|
||||
{
|
||||
return E->GetDisplayNameTextByIndex(SM->ScenarioType).ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return FString();
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget::CloseSettingsPopup()
|
||||
{
|
||||
if (SettingsPopup.IsValid())
|
||||
{
|
||||
SettingsPopup->SetVisibility(EVisibility::Collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget::OnUseDynamicNavigationToggled(ECheckBoxState State)
|
||||
{
|
||||
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
|
||||
if (!SM) return;
|
||||
|
||||
const bool bEnabled = (State == ECheckBoxState::Checked);
|
||||
SM->bUseDynamicNavigation = bEnabled;
|
||||
bSceneDirty = true;
|
||||
PS_Editor_NavUtils::SetDynamicNavigationEnabled(GetWorld(), bEnabled);
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget::OnShowNavMeshToggled(ECheckBoxState State)
|
||||
{
|
||||
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
|
||||
if (!SM) return;
|
||||
|
||||
const bool bVisible = (State == ECheckBoxState::Checked);
|
||||
SM->bShowNavMesh = bVisible;
|
||||
PS_Editor_NavUtils::SetNavMeshVisible(GetWorld(), bVisible);
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget::OnCloseClicked()
|
||||
{
|
||||
if (CloseConfirmOverlay.IsValid())
|
||||
@@ -3844,7 +4207,7 @@ void UPS_Editor_MainWidget::RebuildTimelineTracksList()
|
||||
// previously-selected actor (e.g. a spline still in overlay-visible state)
|
||||
// is properly released and its handles/highlight go away.
|
||||
AActor* TrackActor = TS2->FindActorById(Tr.ActorId);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Key click — track=%d key=%d ActorId=%s resolved=%s"),
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Key click — track=%d key=%d ActorId=%s resolved=%s"),
|
||||
CapturedTrack, CapturedKey, *Tr.ActorId.ToString(), TrackActor ? *TrackActor->GetName() : TEXT("null"));
|
||||
if (TrackActor)
|
||||
{
|
||||
|
||||
@@ -107,6 +107,10 @@ private:
|
||||
TSharedPtr<STextBlock> SnapText;
|
||||
TSharedPtr<SEditableTextBox> SnapSizeField;
|
||||
|
||||
// Floor-cut UI (editor-only horizontal plane that hides primitives above AnchorZ+H)
|
||||
TSharedPtr<STextBlock> FloorCutValueText;
|
||||
TSharedPtr<class SSlider> FloorCutSlider;
|
||||
|
||||
// Spawn catalog
|
||||
TSharedPtr<SVerticalBox> SpawnListContainer;
|
||||
|
||||
@@ -134,6 +138,19 @@ private:
|
||||
void ConfirmBaseLevelSelection();
|
||||
void CancelBaseLevelPopup();
|
||||
|
||||
// Global Settings popup (per-scenario nav option + session-only navmesh visu + scenario type).
|
||||
TSharedPtr<SWidget> SettingsPopup;
|
||||
void OpenSettingsPopup();
|
||||
void CloseSettingsPopup();
|
||||
void OnUseDynamicNavigationToggled(ECheckBoxState State);
|
||||
void OnShowNavMeshToggled(ECheckBoxState State);
|
||||
|
||||
// Scenario type dropdown — populated from MasterCatalog::ScenarioTypeEnum, hidden when null.
|
||||
TArray<TSharedPtr<FString>> ScenarioTypeOptions;
|
||||
TSharedPtr<SComboBox<TSharedPtr<FString>>> ScenarioTypeCombo;
|
||||
void RefreshScenarioTypeOptions();
|
||||
FString GetCurrentScenarioTypeDisplayName() const;
|
||||
|
||||
// Transform fields
|
||||
TSharedPtr<SEditableTextBox> LocX, LocY, LocZ;
|
||||
TSharedPtr<SEditableTextBox> RotX, RotY, RotZ;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "PS_Editor_MainWidget_Legacy.h"
|
||||
#include "PS_Editor_SelectionManager.h"
|
||||
#include "PS_Editor_SpawnManager.h"
|
||||
#include "PS_Editor_NavUtils.h"
|
||||
#include "PS_Editor_SceneSerializer.h"
|
||||
#include "PS_Editor_SceneLoader.h"
|
||||
#include "PS_Editor_UndoManager.h"
|
||||
@@ -31,6 +32,7 @@
|
||||
#include "DesktopPlatformModule.h"
|
||||
#include "PS_Editor_GameInstanceSubsystem.h"
|
||||
#include "PS_Editor_TimelineSubsystem.h"
|
||||
#include "PS_Editor_FloorCutManager.h"
|
||||
#include "Engine/World.h"
|
||||
|
||||
// Forward declaration — the real definition lives further down with the timeline UI block,
|
||||
@@ -448,6 +450,187 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::RebuildWidget()
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
// Settings popup (per-scenario nav option + session navmesh visu)
|
||||
+ SOverlay::Slot()
|
||||
.HAlign(HAlign_Center)
|
||||
.VAlign(VAlign_Center)
|
||||
[
|
||||
SAssignNew(SettingsPopup, SBorder)
|
||||
.Visibility(EVisibility::Collapsed)
|
||||
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
|
||||
.BorderBackgroundColor(FLinearColor(0.08f, 0.08f, 0.08f, 0.97f))
|
||||
.Padding(FMargin(28.0f, 20.0f))
|
||||
[
|
||||
SNew(SVerticalBox)
|
||||
// Header
|
||||
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 0.0f, 0.0f, 16.0f)
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Settings")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 14))
|
||||
.ColorAndOpacity(FLinearColor::White)
|
||||
]
|
||||
// Use Dynamic Navigation (saved per-scenario)
|
||||
+ SVerticalBox::Slot().AutoHeight().Padding(0.0f, 4.0f)
|
||||
[
|
||||
SNew(SHorizontalBox)
|
||||
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0.0f, 0.0f, 8.0f, 0.0f)
|
||||
[
|
||||
SNew(SCheckBox)
|
||||
.IsChecked_Lambda([this]() -> ECheckBoxState
|
||||
{
|
||||
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
|
||||
{
|
||||
return SM->bUseDynamicNavigation ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
|
||||
}
|
||||
return ECheckBoxState::Unchecked;
|
||||
})
|
||||
.OnCheckStateChanged_Lambda([this](ECheckBoxState State)
|
||||
{
|
||||
OnUseDynamicNavigationToggled(State);
|
||||
})
|
||||
]
|
||||
+ SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center)
|
||||
[
|
||||
SNew(SVerticalBox)
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Use Dynamic Navigation")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
|
||||
.ColorAndOpacity(FLinearColor::White)
|
||||
]
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Rebuild navmesh at runtime when actors are spawned/moved (saved with scenario).")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
|
||||
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
|
||||
.AutoWrapText(true)
|
||||
]
|
||||
]
|
||||
]
|
||||
// Show NavMesh (session-only)
|
||||
+ SVerticalBox::Slot().AutoHeight().Padding(0.0f, 8.0f, 0.0f, 4.0f)
|
||||
[
|
||||
SNew(SHorizontalBox)
|
||||
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(0.0f, 0.0f, 8.0f, 0.0f)
|
||||
[
|
||||
SNew(SCheckBox)
|
||||
.IsChecked_Lambda([this]() -> ECheckBoxState
|
||||
{
|
||||
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
|
||||
{
|
||||
return SM->bShowNavMesh ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
|
||||
}
|
||||
return ECheckBoxState::Unchecked;
|
||||
})
|
||||
.OnCheckStateChanged_Lambda([this](ECheckBoxState State)
|
||||
{
|
||||
OnShowNavMeshToggled(State);
|
||||
})
|
||||
]
|
||||
+ SHorizontalBox::Slot().FillWidth(1.0f).VAlign(VAlign_Center)
|
||||
[
|
||||
SNew(SVerticalBox)
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Show NavMesh (P)")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
|
||||
.ColorAndOpacity(FLinearColor::White)
|
||||
]
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Render the navmesh in green (mimics editor's P key). Personal preference, never saved.")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
|
||||
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
|
||||
.AutoWrapText(true)
|
||||
]
|
||||
]
|
||||
]
|
||||
// Scenario Type dropdown — visible only when MasterCatalog::ScenarioTypeEnum is set.
|
||||
+ SVerticalBox::Slot().AutoHeight().Padding(0.0f, 8.0f, 0.0f, 4.0f)
|
||||
[
|
||||
SNew(SHorizontalBox)
|
||||
.Visibility_Lambda([this]() -> EVisibility
|
||||
{
|
||||
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
||||
{
|
||||
if (PC->MasterCatalogAsset && PC->MasterCatalogAsset->ScenarioTypeEnum)
|
||||
{
|
||||
return EVisibility::Visible;
|
||||
}
|
||||
}
|
||||
return EVisibility::Collapsed;
|
||||
})
|
||||
+ SHorizontalBox::Slot().FillWidth(0.45f).VAlign(VAlign_Center)
|
||||
[
|
||||
SNew(SVerticalBox)
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Scenario Type")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
|
||||
.ColorAndOpacity(FLinearColor::White)
|
||||
]
|
||||
+ SVerticalBox::Slot().AutoHeight()
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Saved per-scenario; pushed to PendingScenarioType at Play.")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
|
||||
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f))
|
||||
.AutoWrapText(true)
|
||||
]
|
||||
]
|
||||
+ SHorizontalBox::Slot().FillWidth(0.55f).VAlign(VAlign_Center).Padding(8.0f, 0.0f, 0.0f, 0.0f)
|
||||
[
|
||||
SAssignNew(ScenarioTypeCombo, SComboBox<TSharedPtr<FString>>)
|
||||
.OptionsSource(&ScenarioTypeOptions)
|
||||
.OnComboBoxOpening_Lambda([this]() { RefreshScenarioTypeOptions(); })
|
||||
.OnGenerateWidget_Lambda([](TSharedPtr<FString> Item) -> TSharedRef<SWidget>
|
||||
{
|
||||
return SNew(STextBlock).Text(FText::FromString(Item.IsValid() ? *Item : FString()));
|
||||
})
|
||||
.OnSelectionChanged_Lambda([this](TSharedPtr<FString> NewItem, ESelectInfo::Type SelectType)
|
||||
{
|
||||
if (!NewItem.IsValid()) return;
|
||||
const int32 Idx = ScenarioTypeOptions.IndexOfByKey(NewItem);
|
||||
if (Idx == INDEX_NONE) return;
|
||||
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
|
||||
{
|
||||
SM->ScenarioType = (uint8)Idx;
|
||||
bSceneDirty = true;
|
||||
}
|
||||
})
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text_Lambda([this]() -> FText
|
||||
{
|
||||
return FText::FromString(GetCurrentScenarioTypeDisplayName());
|
||||
})
|
||||
]
|
||||
]
|
||||
]
|
||||
// Close button
|
||||
+ SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0.0f, 16.0f, 0.0f, 0.0f)
|
||||
[
|
||||
SNew(SButton)
|
||||
.OnClicked_Lambda([this]() -> FReply
|
||||
{
|
||||
CloseSettingsPopup();
|
||||
return FReply::Handled();
|
||||
})
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Close")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 11))
|
||||
.ColorAndOpacity(FLinearColor::White)
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@@ -558,6 +741,69 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildToolbar()
|
||||
})
|
||||
]
|
||||
]
|
||||
// ---- Floor cut: editor-only horizontal plane to hide upper floors ----
|
||||
+ SHorizontalBox::Slot().AutoWidth().Padding(16.0f, 0.0f, 2.0f, 0.0f).VAlign(VAlign_Center)
|
||||
[
|
||||
SNew(SButton)
|
||||
.ToolTipText(FText::FromString(TEXT("Toggle floor cut. Hides primitives whose bounds-min Z is above (Anchor.Z + Height)")))
|
||||
.OnClicked_Lambda([this]() -> FReply
|
||||
{
|
||||
if (UWorld* W = GetWorld())
|
||||
{
|
||||
if (UPS_Editor_FloorCutManager* FC = W->GetSubsystem<UPS_Editor_FloorCutManager>())
|
||||
{
|
||||
FC->SetEnabled(!FC->IsEnabled());
|
||||
if (FloorCutToggleText.IsValid())
|
||||
{
|
||||
FloorCutToggleText->SetText(FText::FromString(FC->IsEnabled() ? TEXT("Floor cut: On") : TEXT("Floor cut: Off")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return FReply::Handled();
|
||||
})
|
||||
[
|
||||
SAssignNew(FloorCutToggleText, STextBlock)
|
||||
.Text(FText::FromString(TEXT("Floor cut: Off")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
|
||||
]
|
||||
]
|
||||
+ SHorizontalBox::Slot().AutoWidth().Padding(4.0f, 0.0f).VAlign(VAlign_Center)
|
||||
[
|
||||
SNew(SBox).MinDesiredWidth(120.0f)
|
||||
[
|
||||
SAssignNew(FloorCutSlider, SSlider)
|
||||
.MinValue(0.0f)
|
||||
.MaxValue(1000.0f)
|
||||
.StepSize(10.0f)
|
||||
.MouseUsesStep(true)
|
||||
.Value(200.0f)
|
||||
.ToolTipText(FText::FromString(TEXT("Cut height above MandatoryAnchor (cm) — range 0..1000, step 10")))
|
||||
.OnValueChanged_Lambda([this](float NewValue)
|
||||
{
|
||||
// Snap to 10 cm — SSlider's MouseUsesStep already does this for clicks,
|
||||
// but a drag still emits raw floats, so we re-snap here.
|
||||
const float Snapped = FMath::RoundToFloat(NewValue / 10.f) * 10.f;
|
||||
if (UWorld* W = GetWorld())
|
||||
{
|
||||
if (UPS_Editor_FloorCutManager* FC = W->GetSubsystem<UPS_Editor_FloorCutManager>())
|
||||
{
|
||||
FC->SetHeightAboveAnchor(Snapped);
|
||||
}
|
||||
}
|
||||
if (FloorCutValueText.IsValid())
|
||||
{
|
||||
FloorCutValueText->SetText(FText::FromString(FString::Printf(TEXT("%.0f cm"), Snapped)));
|
||||
}
|
||||
})
|
||||
]
|
||||
]
|
||||
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f).VAlign(VAlign_Center)
|
||||
[
|
||||
SAssignNew(FloorCutValueText, STextBlock)
|
||||
.Text(FText::FromString(TEXT("200 cm")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
|
||||
.MinDesiredWidth(48.0f)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@@ -805,6 +1051,10 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildSceneButtons()
|
||||
if (SceneSerializer.IsValid() && SpawnManager.IsValid())
|
||||
{
|
||||
SceneSerializer->ClearScene(SpawnManager.Get());
|
||||
// Re-spawn mandatory actors immediately after the wipe — this is
|
||||
// the "New Scenario" path, where the user expects a fresh-but-not-empty
|
||||
// scene (mandatory class instances at the MandatoryAnchor).
|
||||
SpawnManager->EnsureMandatoryActorsPresent();
|
||||
bSceneDirty = false;
|
||||
LastKnownSpawnedCount = 0;
|
||||
}
|
||||
@@ -816,6 +1066,25 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildSceneButtons()
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
|
||||
]
|
||||
]
|
||||
// Settings button — opens the global Settings popup
|
||||
+ SHorizontalBox::Slot()
|
||||
.AutoWidth()
|
||||
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
|
||||
[
|
||||
SNew(SButton)
|
||||
.ToolTipText(FText::FromString(TEXT("Open scenario / session settings (dynamic nav, navmesh visu).")))
|
||||
.OnClicked_Lambda([this]() -> FReply
|
||||
{
|
||||
OpenSettingsPopup();
|
||||
return FReply::Handled();
|
||||
})
|
||||
[
|
||||
SNew(STextBlock)
|
||||
.Text(FText::FromString(TEXT("Settings")))
|
||||
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
|
||||
.ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f))
|
||||
]
|
||||
]
|
||||
+ SHorizontalBox::Slot()
|
||||
.AutoWidth()
|
||||
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
|
||||
@@ -869,7 +1138,8 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildSceneButtons()
|
||||
{
|
||||
if (UPS_Editor_GameInstanceSubsystem* Sub = GI->GetSubsystem<UPS_Editor_GameInstanceSubsystem>())
|
||||
{
|
||||
Sub->SetPendingScenario(SceneName, BaseLevel);
|
||||
const uint8 TypeToSend = SpawnManager.IsValid() ? SpawnManager->ScenarioType : 0;
|
||||
Sub->SetPendingScenario(SceneName, BaseLevel, TypeToSend);
|
||||
Sub->bIsPlayMode = true;
|
||||
if (Sub->EditorMapName.IsEmpty())
|
||||
{
|
||||
@@ -986,7 +1256,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildSceneButtons()
|
||||
}
|
||||
}
|
||||
|
||||
SceneSerializer->LoadScene(FileName, SpawnManager.Get());
|
||||
SceneSerializer->LoadScenario(FileName, SpawnManager.Get());
|
||||
bSceneDirty = false;
|
||||
if (SaveNameField.IsValid())
|
||||
{
|
||||
@@ -1239,18 +1509,14 @@ void UPS_Editor_MainWidget_Legacy::PopulateSceneList()
|
||||
// No base level in the scenario file — try to resolve the default from the catalog.
|
||||
if (!bHasValidBaseLevel && EditorPC->MasterCatalogAsset)
|
||||
{
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Scenario '%s' has no base level, searching for default in MasterCatalog (%d entries)"),
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Scenario '%s' has no base level, searching for default in MasterCatalog (%d entries)"),
|
||||
*Name, EditorPC->MasterCatalogAsset->BaseLevels.Num());
|
||||
for (const FPS_Editor_BaseLevelEntry& Entry : EditorPC->MasterCatalogAsset->BaseLevels)
|
||||
{
|
||||
UE_LOG(LogTemp, Log, TEXT(" BaseLevel entry: DisplayName='%s', MapPath='%s', bIsDefault=%d"),
|
||||
*Entry.DisplayName, *Entry.MapPath, Entry.bIsDefault ? 1 : 0);
|
||||
if (Entry.bIsDefault && !Entry.DisplayName.IsEmpty())
|
||||
{
|
||||
SceneLevelName = Entry.DisplayName;
|
||||
bHasValidBaseLevel = BaseLevelPathMap.Contains(SceneLevelName);
|
||||
UE_LOG(LogTemp, Log, TEXT(" -> Picked default '%s' (in PathMap=%d)"),
|
||||
*SceneLevelName, bHasValidBaseLevel ? 1 : 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1285,7 +1551,7 @@ void UPS_Editor_MainWidget_Legacy::PopulateSceneList()
|
||||
// current sublevel as-is — don't unload it (avoids empty scenes).
|
||||
}
|
||||
|
||||
SceneSerializer->LoadScene(Name, SpawnManager.Get());
|
||||
SceneSerializer->LoadScenario(Name, SpawnManager.Get());
|
||||
bSceneDirty = false;
|
||||
if (SaveNameField.IsValid())
|
||||
{
|
||||
@@ -1369,6 +1635,10 @@ void UPS_Editor_MainWidget_Legacy::PopulateSpawnCatalog()
|
||||
{
|
||||
const FPS_Editor_ResolvedEntry& Entry = Entries[i];
|
||||
|
||||
// Mandatory classes are auto-spawned and singletons — hide them from the catalog so
|
||||
// the user can't add a 2nd instance.
|
||||
if (Entry.bIsMandatory) continue;
|
||||
|
||||
// Category header
|
||||
if (Entry.Category != CurrentCategory)
|
||||
{
|
||||
@@ -1573,9 +1843,30 @@ void UPS_Editor_MainWidget_Legacy::RefreshPropertiesFromSelection()
|
||||
bPointSelected = (Pawn->ActiveSplinePointIndex >= 0);
|
||||
}
|
||||
|
||||
// SplinePlacement mode is shared with IPS_Editor_ChildMovable — show the right help.
|
||||
bool bChildMovableActive = false;
|
||||
if (APS_Editor_PlayerController* EditorPC2 = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
||||
{
|
||||
if (AActor* ActiveActor = EditorPC2->GetActivePlacementActor())
|
||||
{
|
||||
bChildMovableActive = ActiveActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass());
|
||||
}
|
||||
}
|
||||
|
||||
if (HelpBarText.IsValid())
|
||||
{
|
||||
if (bPointSelected)
|
||||
if (bChildMovableActive)
|
||||
{
|
||||
if (bPointSelected)
|
||||
{
|
||||
HelpBarText->SetText(FText::FromString(TEXT("CHILDREN: Drag gizmo to move | R: rotate | T: translate | Esc/Done: finish")));
|
||||
}
|
||||
else
|
||||
{
|
||||
HelpBarText->SetText(FText::FromString(TEXT("CHILDREN: Click a child to select | Esc/Done: finish")));
|
||||
}
|
||||
}
|
||||
else if (bPointSelected)
|
||||
{
|
||||
HelpBarText->SetText(FText::FromString(TEXT("SPLINE: Drag gizmo to move | Del: delete point | Click line: insert | Click outside: add | Esc/Done: finish")));
|
||||
}
|
||||
@@ -1587,7 +1878,8 @@ void UPS_Editor_MainWidget_Legacy::RefreshPropertiesFromSelection()
|
||||
}
|
||||
if (SplineEditButton.IsValid()) SplineEditButton->SetVisibility(EVisibility::Collapsed);
|
||||
if (ChildEditButton.IsValid()) ChildEditButton->SetVisibility(EVisibility::Collapsed);
|
||||
if (SplineDeletePointButton.IsValid()) SplineDeletePointButton->SetVisibility(bPointSelected ? EVisibility::Visible : EVisibility::Collapsed);
|
||||
// Delete-point is meaningless for ChildMovable (fixed child set)
|
||||
if (SplineDeletePointButton.IsValid()) SplineDeletePointButton->SetVisibility((bPointSelected && !bChildMovableActive) ? EVisibility::Visible : EVisibility::Collapsed);
|
||||
if (SplineFinishButton.IsValid()) SplineFinishButton->SetVisibility(EVisibility::Visible);
|
||||
}
|
||||
else if (bChildMovableSelected)
|
||||
@@ -1908,7 +2200,7 @@ void UPS_Editor_MainWidget_Legacy::RebuildDynamicProperties()
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: EditableComponent has 0 properties on %s"), *Actor->GetName());
|
||||
return;
|
||||
}
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Building %d dynamic property rows for %s"), EditComp->EditableProperties.Num(), *Actor->GetName());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Building %d dynamic property rows for %s"), EditComp->EditableProperties.Num(), *Actor->GetName());
|
||||
|
||||
const FSlateFontInfo LabelFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
|
||||
const FSlateFontInfo ValueFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
|
||||
@@ -1936,8 +2228,6 @@ void UPS_Editor_MainWidget_Legacy::RebuildDynamicProperties()
|
||||
Row.DisplayName = EditComp->GetDisplayName(Entry.Path);
|
||||
Row.Key = Entry.Path.ToString();
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolving [%s] comp='%s' prop='%s'"), *Row.Key, *Row.ComponentName, *Row.PropertyName);
|
||||
|
||||
UObject* Target = FindPropertyTarget(Actor, Row.ComponentName);
|
||||
if (!Target)
|
||||
{
|
||||
@@ -1958,8 +2248,6 @@ void UPS_Editor_MainWidget_Legacy::RebuildDynamicProperties()
|
||||
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Property '%s' not found on %s (%s)"), *Row.PropertyName, *Target->GetName(), *Target->GetClass()->GetName());
|
||||
continue;
|
||||
}
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolved OK → %s on %s"), *Row.CachedProperty->GetName(), *Target->GetClass()->GetName());
|
||||
|
||||
const int32 RowIndex = DynamicPropertyRows.Num();
|
||||
|
||||
// Label
|
||||
@@ -2206,7 +2494,7 @@ void UPS_Editor_MainWidget_Legacy::RebuildDynamicProperties()
|
||||
DynamicPropertyRows.Add(Row);
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Built %d dynamic property row(s)"), DynamicPropertyRows.Num());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Built %d dynamic property row(s)"), DynamicPropertyRows.Num());
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget_Legacy::RefreshDynamicPropertyValues()
|
||||
@@ -2539,7 +2827,7 @@ void UPS_Editor_MainWidget_Legacy::ApplyPropertyFromUI(int32 RowIndex)
|
||||
if (UFunction* RepFunc = Target->FindFunction(Row.CachedProperty->RepNotifyFunc))
|
||||
{
|
||||
Target->ProcessEvent(RepFunc, nullptr);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Called %s on %s"), *Row.CachedProperty->RepNotifyFunc.ToString(), *Target->GetName());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Called %s on %s"), *Row.CachedProperty->RepNotifyFunc.ToString(), *Target->GetName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2566,7 +2854,7 @@ void UPS_Editor_MainWidget_Legacy::ApplyPropertyFromUI(int32 RowIndex)
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Property %s changed: %s → %s"), *Row.Key, *OldValue, *NewValue);
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Property %s changed: %s → %s"), *Row.Key, *OldValue, *NewValue);
|
||||
|
||||
// Always notify property changed
|
||||
const bool bImplementsInterface = Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass());
|
||||
@@ -2846,6 +3134,99 @@ void UPS_Editor_MainWidget_Legacy::CancelBaseLevelPopup()
|
||||
PendingBaseLevelSelection.Empty();
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget_Legacy::OpenSettingsPopup()
|
||||
{
|
||||
if (SettingsPopup.IsValid())
|
||||
{
|
||||
// Refresh enum-driven options each time the popup opens — covers the case where the
|
||||
// MasterCatalogAsset (or its ScenarioTypeEnum) wasn't set at widget Construct time.
|
||||
RefreshScenarioTypeOptions();
|
||||
SettingsPopup->SetVisibility(EVisibility::Visible);
|
||||
}
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget_Legacy::RefreshScenarioTypeOptions()
|
||||
{
|
||||
ScenarioTypeOptions.Empty();
|
||||
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
||||
{
|
||||
if (PC->MasterCatalogAsset && PC->MasterCatalogAsset->ScenarioTypeEnum)
|
||||
{
|
||||
UEnum* E = PC->MasterCatalogAsset->ScenarioTypeEnum;
|
||||
const int32 Count = FMath::Max(0, E->NumEnums() - 1);
|
||||
for (int32 i = 0; i < Count; ++i)
|
||||
{
|
||||
ScenarioTypeOptions.Add(MakeShared<FString>(E->GetDisplayNameTextByIndex(i).ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ScenarioTypeCombo.IsValid())
|
||||
{
|
||||
ScenarioTypeCombo->RefreshOptions();
|
||||
}
|
||||
}
|
||||
|
||||
FString UPS_Editor_MainWidget_Legacy::GetCurrentScenarioTypeDisplayName() const
|
||||
{
|
||||
if (UPS_Editor_SpawnManager* SM = SpawnManager.Get())
|
||||
{
|
||||
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
||||
{
|
||||
if (PC->MasterCatalogAsset && PC->MasterCatalogAsset->ScenarioTypeEnum)
|
||||
{
|
||||
UEnum* E = PC->MasterCatalogAsset->ScenarioTypeEnum;
|
||||
const int32 Count = FMath::Max(0, E->NumEnums() - 1);
|
||||
if (SM->ScenarioType < Count)
|
||||
{
|
||||
return E->GetDisplayNameTextByIndex(SM->ScenarioType).ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return FString();
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget_Legacy::CloseSettingsPopup()
|
||||
{
|
||||
if (SettingsPopup.IsValid())
|
||||
{
|
||||
SettingsPopup->SetVisibility(EVisibility::Collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget_Legacy::OnUseDynamicNavigationToggled(ECheckBoxState State)
|
||||
{
|
||||
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
|
||||
if (!SM) return;
|
||||
|
||||
const bool bEnabled = (State == ECheckBoxState::Checked);
|
||||
SM->bUseDynamicNavigation = bEnabled;
|
||||
bSceneDirty = true; // user changed a per-scenario setting
|
||||
|
||||
// Apply live to the level's RecastNavMesh actors.
|
||||
PS_Editor_NavUtils::SetDynamicNavigationEnabled(GetWorld(), bEnabled);
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget_Legacy::OnShowNavMeshToggled(ECheckBoxState State)
|
||||
{
|
||||
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
|
||||
if (!SM) return;
|
||||
|
||||
const bool bVisible = (State == ECheckBoxState::Checked);
|
||||
SM->bShowNavMesh = bVisible;
|
||||
|
||||
PS_Editor_NavUtils::SetNavMeshVisible(GetWorld(), bVisible);
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget_Legacy::ToggleShowNavMesh()
|
||||
{
|
||||
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
|
||||
if (!SM) return;
|
||||
|
||||
SM->bShowNavMesh = !SM->bShowNavMesh;
|
||||
PS_Editor_NavUtils::SetNavMeshVisible(GetWorld(), SM->bShowNavMesh);
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget_Legacy::OnCloseClicked()
|
||||
{
|
||||
if (CloseConfirmOverlay.IsValid())
|
||||
@@ -3472,7 +3853,7 @@ void UPS_Editor_MainWidget_Legacy::RebuildTimelineTracksList()
|
||||
// previously-selected actor (e.g. a spline still in overlay-visible state)
|
||||
// is properly released and its handles/highlight go away.
|
||||
AActor* TrackActor = TS2->FindActorById(Tr.ActorId);
|
||||
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Key click — track=%d key=%d ActorId=%s resolved=%s"),
|
||||
UE_LOG(LogTemp, Verbose, TEXT("PS_Editor: Key click — track=%d key=%d ActorId=%s resolved=%s"),
|
||||
CapturedTrack, CapturedKey, *Tr.ActorId.ToString(), TrackActor ? *TrackActor->GetName() : TEXT("null"));
|
||||
if (TrackActor)
|
||||
{
|
||||
|
||||
@@ -80,6 +80,11 @@ private:
|
||||
TSharedPtr<STextBlock> SnapText;
|
||||
TSharedPtr<SEditableTextBox> SnapSizeField;
|
||||
|
||||
// Floor-cut UI (editor-only horizontal plane that hides primitives above AnchorZ+H)
|
||||
TSharedPtr<STextBlock> FloorCutToggleText;
|
||||
TSharedPtr<class SSlider> FloorCutSlider;
|
||||
TSharedPtr<STextBlock> FloorCutValueText;
|
||||
|
||||
// Spawn catalog
|
||||
TSharedPtr<SVerticalBox> SpawnListContainer;
|
||||
|
||||
@@ -107,6 +112,21 @@ private:
|
||||
void ConfirmBaseLevelSelection();
|
||||
void CancelBaseLevelPopup();
|
||||
|
||||
// Global Settings popup (per-scenario nav option + session-only navmesh visu + scenario type).
|
||||
TSharedPtr<SWidget> SettingsPopup;
|
||||
void OpenSettingsPopup();
|
||||
void CloseSettingsPopup();
|
||||
void OnUseDynamicNavigationToggled(ECheckBoxState State);
|
||||
void OnShowNavMeshToggled(ECheckBoxState State);
|
||||
/** Toggle the navmesh visualization (P key handler). */
|
||||
void ToggleShowNavMesh();
|
||||
|
||||
// Scenario type dropdown — populated from MasterCatalog::ScenarioTypeEnum, hidden when null.
|
||||
TArray<TSharedPtr<FString>> ScenarioTypeOptions;
|
||||
TSharedPtr<SComboBox<TSharedPtr<FString>>> ScenarioTypeCombo;
|
||||
void RefreshScenarioTypeOptions();
|
||||
FString GetCurrentScenarioTypeDisplayName() const;
|
||||
|
||||
// Transform fields
|
||||
TSharedPtr<SEditableTextBox> LocX, LocY, LocZ;
|
||||
TSharedPtr<SEditableTextBox> RotX, RotY, RotZ;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
#include "PS_Editor_SpawnManager.h"
|
||||
#include "PS_Editor_SceneSerializer.h"
|
||||
#include "PS_Editor_PlayerController.h"
|
||||
#include "PS_Editor_FloorCutManager.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "Engine/World.h"
|
||||
|
||||
void UPS_Editor_MainWidget_UMG::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager)
|
||||
{
|
||||
@@ -130,7 +132,7 @@ bool UPS_Editor_MainWidget_UMG::LoadScenario(const FString& Name)
|
||||
}
|
||||
if (!SM) SM = SpawnManager.Get();
|
||||
if (!Ser || !SM || Name.IsEmpty()) return false;
|
||||
Ser->LoadScene(Name, SM);
|
||||
Ser->LoadScenario(Name, SM);
|
||||
OnSceneDirty.Broadcast();
|
||||
return true;
|
||||
}
|
||||
@@ -161,6 +163,54 @@ void UPS_Editor_MainWidget_UMG::ClearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- Floor cut --------------------
|
||||
|
||||
void UPS_Editor_MainWidget_UMG::SetFloorCutEnabled(bool bEnabled)
|
||||
{
|
||||
if (UWorld* W = GetWorld())
|
||||
{
|
||||
if (UPS_Editor_FloorCutManager* FC = W->GetSubsystem<UPS_Editor_FloorCutManager>())
|
||||
{
|
||||
FC->SetEnabled(bEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UPS_Editor_MainWidget_UMG::SetFloorCutHeightCm(float HeightCm)
|
||||
{
|
||||
if (UWorld* W = GetWorld())
|
||||
{
|
||||
if (UPS_Editor_FloorCutManager* FC = W->GetSubsystem<UPS_Editor_FloorCutManager>())
|
||||
{
|
||||
FC->SetHeightAboveAnchor(HeightCm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool UPS_Editor_MainWidget_UMG::IsFloorCutEnabled() const
|
||||
{
|
||||
if (UWorld* W = GetWorld())
|
||||
{
|
||||
if (UPS_Editor_FloorCutManager* FC = W->GetSubsystem<UPS_Editor_FloorCutManager>())
|
||||
{
|
||||
return FC->IsEnabled();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
float UPS_Editor_MainWidget_UMG::GetFloorCutHeightCm() const
|
||||
{
|
||||
if (UWorld* W = GetWorld())
|
||||
{
|
||||
if (UPS_Editor_FloorCutManager* FC = W->GetSubsystem<UPS_Editor_FloorCutManager>())
|
||||
{
|
||||
return FC->GetHeightAboveAnchor();
|
||||
}
|
||||
}
|
||||
return 200.f;
|
||||
}
|
||||
|
||||
// -------------------- Getters --------------------
|
||||
|
||||
EPS_Editor_TransformMode UPS_Editor_MainWidget_UMG::GetCurrentTransformMode() const
|
||||
|
||||
@@ -70,6 +70,22 @@ public:
|
||||
UFUNCTION(BlueprintCallable, Category = "PS Editor|Selection")
|
||||
void ClearSelection();
|
||||
|
||||
// ---- Floor cut (editor-only horizontal plane that hides upper floors) ----
|
||||
|
||||
/** Enable/disable the floor cut. Slider value drives the cut height in cm above the
|
||||
* MandatoryAnchor. */
|
||||
UFUNCTION(BlueprintCallable, Category = "PS Editor|FloorCut")
|
||||
void SetFloorCutEnabled(bool bEnabled);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "PS Editor|FloorCut")
|
||||
void SetFloorCutHeightCm(float HeightCm);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "PS Editor|FloorCut")
|
||||
bool IsFloorCutEnabled() const;
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "PS Editor|FloorCut")
|
||||
float GetFloorCutHeightCm() const;
|
||||
|
||||
// ==================== BP-PURE GETTERS ====================
|
||||
UFUNCTION(BlueprintPure, Category = "PS Editor|Tools")
|
||||
EPS_Editor_TransformMode GetCurrentTransformMode() const;
|
||||
|
||||
@@ -185,6 +185,19 @@ void FPS_Editor_BlueprintFromMesh::CreateBlueprintForAsset(UObject* MeshAsset)
|
||||
USCS_Node* EditableNode = SCS->CreateNode(UPS_Editor_EditableComponent::StaticClass(), FName("PS_Editor_Editable"));
|
||||
SCS->AddNode(EditableNode);
|
||||
|
||||
// Enable replication on the generated BP — without it the actor is server-local only and
|
||||
// clients never see it spawn. SetReplicates() is the public path (the bReplicates field
|
||||
// itself is protected). SetReplicateMovement is paired so any server-side transform change
|
||||
// (timeline keyframe, scripted move) propagates to clients.
|
||||
if (UClass* GeneratedClass = Blueprint->GeneratedClass)
|
||||
{
|
||||
if (AActor* CDO = Cast<AActor>(GeneratedClass->GetDefaultObject()))
|
||||
{
|
||||
CDO->SetReplicates(true);
|
||||
CDO->SetReplicateMovement(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Compile and save
|
||||
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint);
|
||||
FKismetEditorUtilities::CompileBlueprint(Blueprint);
|
||||
|
||||
Reference in New Issue
Block a user