Compare commits

..

17 Commits

Author SHA1 Message Date
6eca4fc94b Add editor-only floor cut to hide primitives above the MandatoryAnchor
Per-component visibility/collision toggle gated on bounds-min Z vs
(AnchorZ + Height). Snapshotted before mutation and restored on
disable, so the feature is non-destructive. Wired into the toolbars of
both Slate widgets and exposed as Blueprint methods on the UMG widget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:07:28 +02:00
4a98c6402b Rename LoadScene/LoadSceneAdditive to LoadScenario/UnloadScenario
The old naming suggested UE level loading; what these calls actually do
is spawn actors described by a .pss scenario file into the already-loaded
UE level. Renamed for clarity:

  SceneLoader::LoadScene         -> LoadScenario
  SceneLoader::LoadSceneFromData -> LoadScenarioFromData
  SceneLoader::LoadSceneAdditive -> dropped (merged into LoadScenario)
  SceneLoader::UnloadSceneAdditive -> UnloadScenario
  SceneSerializer::LoadScene     -> LoadScenario
  AdditiveSpawnedActors          -> TrackedSpawnedActors

LoadScenario now branches on the filter: when Filter == PreloadOnly the
timeline subsystem is skipped entirely (no SetData, no ActorId
registration, no auto-play) so a briefing scene can drop in player
placement markers without disturbing its own timeline. All other filter
values keep the full gameplay behaviour (timeline + AI-readiness poll).

Every load tracks its actors so a follow-up UnloadScenario() can destroy
them — supports the briefing flow where the player toggles between
scenarios at runtime.

Breaking change for external BP callers: any node binding the old
LoadScene / LoadSceneAdditive / UnloadSceneAdditive functions will show
up as missing on next BP compile; replace with LoadScenario /
UnloadScenario (identical or simpler signatures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:42:57 +02:00
f6efc00c73 Add LoadSceneAdditive / UnloadSceneAdditive for briefing scene preload
Lets a briefing scene materialize only the preload-tagged actors from a
scenario .pss (player placement markers, briefing props) without
clearing the current world, pushing timeline data, registering ActorIds,
or starting any auto-play. The actors spawned by each LoadSceneAdditive
call are tracked in a static TWeakObjectPtr array; UnloadSceneAdditive
destroys every still-valid one and clears the array.

Typical BP usage on scenario change:
  UnloadSceneAdditive(this) -> LoadSceneAdditive(this, NewSceneName)

No BP-side bookkeeping required. Filter defaults to PreloadOnly which
matches the intended briefing use case; the parameter stays exposed for
other niche cases (ExcludePreload, All).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:10:52 +02:00
34280de44a Update memory notes for UE 5.7 host project (was 5.5)
Host project migrated from PROSERVE_UE_5_5 to PROSERVE_UE_5_7 in April
2026. CLAUDE.md, memory index, project notes, and build reference now
point at the new path / target / engine version. The 5.5 host is
flagged deprecated to avoid accidental builds against it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:09:17 +02:00
ac91e4e8f9 Bug Fix 2026-05-06 17:53:21 +02:00
c7ec3e9653 Per-scenario type field driven by project enum (Settings dropdown + JSON peek)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:04:59 +02:00
d9ed016297 Reset bIsEditorModeActive on editor-PC EndPlay and SceneLoader entry
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:28:39 +02:00
67ac3ffab7 Demote diagnostic logs to Verbose + drop redundant ones
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:29:36 +02:00
8f7476d2db Mandatory actors auto-spawn + navmesh surface overlay (custom material)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:14:27 +02:00
b9ec67f9ff Runtime nav settings + custom debug viz (P key) + editable restore on stop
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:46:27 +02:00
2a53d12e68 Per-scenario dynamic nav opt-in + Settings popup + timeline restore fixes
Dynamic navigation toggle (per scenario):
- New PS_Editor_NavUtils helpers wrap UNavigationSystemV1 / RecastNavMesh
  protected APIs (RuntimeGeneration field + bEnableDrawing) via the C++
  using-trick so we can switch the level's nav mode and toggle visualization
  without engine modifications.
- FPS_Editor_SceneData gets bUseDynamicNavigation persisted in JSON.
- SpawnManager carries the live state plus a session-only bShowNavMesh
  preference (never saved).
- SceneSerializer reads/writes the flag and applies it on load (editor mode).
- SceneLoader applies it on the gameplay path before spawning actors so
  scenarios that need runtime nav rebuild get it both in editor and at
  runtime via the project's GameMode + SceneLoader::LoadScene flow.

UI:
- New "Settings" toolbar button in both Legacy and UMG widgets opens a popup
  with two checkboxes:
    * Use Dynamic Navigation - saved per scenario, live toggle
    * Show NavMesh (P) - session only, mimics editor's P key (green nav
      visualization)
- Pawn binds P to IA_ToggleNavMesh -> SpawnManager.bShowNavMesh, in sync
  with the popup checkbox.

Timeline restore correctness:
- AddOrUpdateKeyAtCurrentTime, RemoveKey, SetKeyTime, SetKeyInterpolate now
  invalidate LastAppliedValues[TrackIndex] so a key edit isn't masked by a
  stale per-track cache entry from a previous apply.
- RestoreBaseline now prefers a key at t=0 (within epsilon) over the
  captured pre-animation baseline. Updating a t=0 key + Stop now restores
  the new value instead of snapping back to the original. Tracks without a
  t=0 key still fall back to the pre-animation baseline.

Restart simulation rewind:
- Restart() used to call Stop() + Play() but Play immediately cancelled the
  pending RequestStopSimulation timer set by Stop, so transforms were never
  restored - characters stayed where they were.
- RequestStopSimulation now takes bImmediate; when true, OnEditorBeforeSimulateStop
  still fires (so BP behaviour-reset hooks run) but StopSimulation runs
  synchronously, restoring transforms before Play() recaptures them.
- Restart inlines the reset (RestoreBaseline + immediate stop + Play) so
  rewinding actually rewinds.

Build:
- Re-add NavigationSystem to PS_Editor.Build.cs (needed for ARecastNavMesh
  + ANavigationData access in NavUtils).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 10:40:18 +02:00
49cb480e15 Add F key to frame the current selection (Maya/Blender style)
Pressing F computes the combined bounding box of every selected actor,
then animates the camera over 0.25s (smoothstep ease) to a distance that
fits the box in view while keeping the current viewing direction. The
orbit focal point and distance are synced so a subsequent Alt+drag
pivots around the new center.

No-ops cleanly when nothing is selected. Falls back to actor pivots
when no component reports valid bounds, and clamps the radius to a
minimum so point-like actors don't pull the camera onto them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:00:54 +02:00
7f2600f0b8 ChildMovable: rotation support + ChildMovable-aware help bar + clear previous highlight
- IPS_Editor_ChildMovable gains GetChildRotation / RotateChild and the
  GetAll/SetAll rotation pair for undo snapshots. BP impls can clamp axes
  (e.g. Yaw-only) inside RotateChild.
- Pawn captures initial rotations at gizmo drag start and applies the
  rotate gizmo's delta-quat onto the active child only (no longer
  rotating every selected actor when a child handle is active).
- FPS_Editor_SplinePointAction carries optional Old/NewRotations arrays
  applied alongside positions on Undo/Redo for ChildMovable actors.
- Pawn now calls ClearChildHighlight before HighlightChild so picking a
  new child no longer leaves the previous one stuck in highlight state.
- MainWidget + Legacy widget detect when ActivePlacementActor is a
  ChildMovable in SplinePlacement mode and swap the help bar text
  ("CHILDREN: ...") + hide the irrelevant Delete-point button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:07:39 +02:00
c520f3ccd9 Group BP interface functions under "PS Editor|Editable" / "PS Editor|Child Movable"
The two BlueprintNativeEvent interfaces previously dumped every function
into a flat "PS Editor" category, so the BP class members panel listed
them all together. Use the pipe-separated category to put each interface
under its own submenu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:44:25 +02:00
2f8633d23b Flatten BP categories: drop "ASTERION|" prefix and inner-struct categories
Two cleanups in one pass:

1. Replace `Category = "ASTERION|PS_Editor"` (and its sub-pipe variants like
   "...|Timeline", "...|Spline", "...|UI", "...|Child Movable") with a single
   flat `Category = "PS_Editor"` everywhere — UFUNCTIONs and class-level
   UPROPERTYs alike. The pipe-separated hierarchy was redundant (the plugin
   already lives in a "PS_Editor" namespace), and the BP right-click search
   gets a cleaner single-segment grouping.

2. Drop the `Category` attribute from struct field UPROPERTYs in
   FPS_Editor_SpawnEntry, FPS_Editor_BaseLevelEntry, and
   FPS_Editor_EditablePropertyEntry. Struct fields don't need a category —
   they inherit display from the parent property's category. Keeping a
   category on inner fields was causing the SpawnCatalog details panel to
   render the Entries array TWICE (once under the literal "ASTERION|PS Editor"
   header pulled from the inner ActorClass field, once under the proper
   "ASTERION > PS_Editor" hierarchy from the outer Entries property).

Also normalize the two `UCLASS(ClassGroup = "ASTERION|PS Editor")` declarations
on EditableComponent and SpawnableComponent to `ClassGroup = "PS Editor"` for
consistency.

Touches 13 header files; no behavior change — Categories are purely cosmetic
(Details panel grouping, BP right-click menu organization).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:43:34 +02:00
98de4dff6f Document PIE-only nav quirk: AI testing requires Standalone Game
Several attempts were made to fix a PIE-specific RecastNavMesh registration
failure that occurs when the editor's "Play" button calls OpenLevel(BaseLevel)
from inside a PIE session — the level's RecastNavMesh ends up pending-kill
and UE silently substitutes an empty AbstractNavData fallback, breaking AI
pathfinding. Workarounds tried (and reverted): cascading re-registrations
through RegisterNavData / OnInitializeActors / AddNavigationSystemToWorld /
Build / SpawnMissingNavigationData; intermediate transition map; duplicated
BaseLevel sublevel suffixed _default. None worked — the cause is internal
to PIE's OpenLevel handling.

Standalone Game and packaged builds initialize the nav system correctly,
so production is unaffected. Document the workflow so future contributors
don't waste time chasing this PIE-only bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:38:03 +02:00
b51f063d52 Pivot-anchor fallback for no-collision spawns + always-snap on catalog
Ground snap:
- When an actor has no colliding components at all (e.g. a VR start
  marker, placement helper, or any BP whose collision is only enabled
  on entering editor mode), SnapActorToGround now anchors the PIVOT
  directly on the ground instead of falling back to the full-bounds
  bbox bottom. The full-bounds bottom could be far below the pivot
  (helper meshes, arrows, visual markers) and was lifting objects
  hundreds of units into the air.
- Actors with collision keep the original behavior (collision-bbox
  bottom rests on the ground).

Catalog spawn:
- Always snap to ground (drop the bAutoSnapToGround gate) — a user
  clicking in the catalog expects the object to land on the ground
  every time; the flag is kept for gameplay-side spawning via
  SceneLoader.
- SpawnActor now uses AlwaysSpawn instead of
  AdjustIfPossibleButAlwaysSpawn so UE never pushes the actor
  vertically to resolve overlaps — SnapActorToGround is the
  authoritative placement step.

CLAUDE.md workflow rule:
- Document that behavior / logic changes require explaining the plan
  first and waiting for go-ahead; only diagnostic-only additions
  (temporary UE_LOG) are fine without approval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:53:01 +02:00
49 changed files with 2914 additions and 270 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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:

View File

@@ -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
};

View File

@@ -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;
}

View File

@@ -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);
};

View File

@@ -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;
};

View File

@@ -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,6 +482,21 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
const FQuat RotationQuat(AxisDir, FMath::DegreesToRadians(SnapAngle));
const FVector GizmoLoc = GizmoDragPlaneOrigin;
// 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))
{
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)
{
if (AActor* Actor = Selected[i].Get())
@@ -460,6 +509,7 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
}
}
}
}
else if (Mode == EPS_Editor_TransformMode::Scale)
{
// Use combined mouse X+Y screen movement to drive scale
@@ -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());
}
}

View File

@@ -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();

View File

@@ -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).
// Skip the debounce when bImmediate (Restart wants to flush right now).
if (!bImmediate)
{
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;
}
}

View File

@@ -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;

View File

@@ -33,7 +33,8 @@ public class PS_Editor : ModuleRules
"JsonUtilities",
"ProceduralMeshComponent",
"DesktopPlatform",
"AIModule"
"AIModule",
"NavigationSystem"
});
PrivateDependencyModuleNames.AddRange(new string[]

View File

@@ -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>())

View File

@@ -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);
}
}

View File

@@ -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());
}

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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;

View File

@@ -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);
};

View File

@@ -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

View File

@@ -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()

View File

@@ -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;
};

View File

@@ -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 (01000 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();
}

View File

@@ -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;
};

View File

@@ -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);
}

View File

@@ -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
};

View File

@@ -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);
}
}
}
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 ----

View File

@@ -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();
}

View File

@@ -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.

View File

@@ -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). */

View File

@@ -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 (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)
{

View File

@@ -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;

View File

@@ -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 (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)
{

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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);