Compare commits

...

23 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
93b9c7d927 template demo 2026-04-24 10:44:44 +02:00
050ea4eb2f Gizmo follow, cooker-friendly assets, and proper ground snapping
Gizmo usability:
- Refresh the gizmo every tick while simulation is running so it glues to
  animated / AI-driven / timeline-driven actors instead of freezing at the
  pose they had when selected
- Clear the pawn's active sub-element drag state on FinishPointPlacement /
  CancelPointPlacement — previously, after clicking Done on a spline, the
  gizmo jumped to the center cube but the internal ActiveSplinePointIndex
  stayed on the last clicked CP, so dragging the gizmo moved that CP
  instead of the whole actor
- Expose UpdateGizmo() publicly on the SelectionManager so the
  PlayerController Tick can drive it

Package cooking (fix gray gizmos / missing outline in packaged builds):
- Convert every runtime LoadObject<> soft-string path into a UPROPERTY
  TObjectPtr<> filled via ConstructorHelpers::FObjectFinder at CDO
  construction time. Hard refs are picked up by the cooker so plugin
  Content assets (M_PS_Editor_Gizmo, M_PS_Editor_SelectionOutline,
  M_PS_Editor_SplineLine, and the engine basic shapes the spline actor
  uses) are reliably staged
- Touches APS_Editor_Gizmo, APS_Editor_Pawn (outline post-process
  material), APS_Editor_SplineActor (gizmo material, spline line
  material, center cube mesh, handle sphere mesh)

Ground placement (PS_Editor_GroundSnap):
- New PS_Editor_GroundSnap utility centralising placement logic for
  catalogue spawns, End-key snap-to-ground, and spline CP placement
- ProjectCameraRayToGround: aim down from a point in front of the camera
  when the camera ray misses (no more "looking at sky spawns object in
  mid-air"); classify the primary hit by normal to re-cast from below a
  ceiling / step back from a wall so the object lands on the floor
  underneath / at the foot of the surface rather than embedded in it
- SnapActorToGround: start the downward ray from INSIDE the actor's
  volume (pivot + small buffer), ignoring the actor itself — avoids the
  "raised a bit high, pressed End, teleports to upper story" bug where
  starting above the actor's bbox could cross the current room's ceiling
  and find the upper floor as the first floor-like surface
- Uses GetActorBounds(bOnlyCollidingComponents=true) with a fallback so
  characters snap from their capsule bounds rather than bbox including
  non-colliding meshes / attached weapons / child actors that used to
  lift pawns into the air
- Multi-hit trace + normal.Z >= 0.3 filter skips walls, ceilings, and
  backfaces when finding the first true floor below

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:43:45 +02:00
9877c1b6de Fix (missing) timeline tracks + lost editable properties on 2nd scene load
FindActorById now skips stale TWeakObjectPtr entries instead of returning
the first null match, RegisterActorId prunes stale + same-GUID entries
before adding, and Clear() resets the ActorIds map so a fresh scenario
starts with no ghost references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:08:26 +02:00
721c9bb5c3 Unify simulate/timeline controls + BeforeStop event + AI auto-possess safety net
- Remove the Simulate toolbar button (both Legacy + UMG widgets). Play /
  Pause / Restart / Stop now live exclusively on the Timeline panel, and
  the panel is visible by default (no more toggle button on the toolbar).
  Keeps a global Play button on the Legacy toolbar that opens the level
  outside the editor.

- Move the grace-period / deferred-stop logic out of the TimelineSubsystem
  and into APS_Editor_PlayerController::RequestStopSimulation. This lets
  the timeline Stop button AND the UMG topbar simulate buttons share the
  same event sequence: OnEditorBeforeSimulateStop fires immediately (after
  RestoreBaseline), then a 500ms BT grace period, then StopSimulation
  fires OnEditorSimulateStop and cuts AI. New event
  OnEditorBeforeSimulateStop added to the editable interface with full
  timing docs.

- Drop the early-return in TimelineSubsystem::Play when Tracks.Num()==0 —
  the timeline Play button is now the only way to start the sim, so it
  must drive StartSimulation unconditionally.

- StartSimulation now force-calls SpawnDefaultController() on any pawn
  that enters sim without a Controller but has an AIControllerClass set,
  so AutoPossessAI=Disabled or race-lost possessions don't leave the BT
  unreachable.

- Route the UMG topbar Play / Simulate buttons and the "simulate" command
  through RequestStopSimulation too, so every path emits the full event
  sequence consistently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:52:48 +02:00
e999a11349 Timeline: pre-animation baseline restore on Stop + display cache
Stop now rewinds animated properties to the value they had before the
timeline first acted on them — not "first keyframe's value" as before.

- New per-track baseline stored runtime-only in a TMap keyed by
  (ActorId, PropertyPath), so it survives track reordering and isn't
  persisted to JSON (avoids stale data in legacy scenarios).
- Captured once: at track creation (first "+" on a fresh property) or
  at SetData time for tracks loaded from a scenario. Never re-captured,
  so scrub-then-Play doesn't shift the baseline to whatever the cursor
  happened to be parked on.
- Stop writes the baselines back via ImportText, fires OnEditorPropertyChanged
  + RepNotify per track, then defers the actual StopSimulation by 100ms
  so the BehaviorTree has a grace period to see the restored values via
  OnRep / OnEditorPropertyChanged and transition to a neutral state before
  AI tick is cut.
- Play cancels any in-flight deferred StopSimulation (Restart = Stop +
  Play keeps the simulation running across the handoff).
- SetData and Clear reset the baseline map so loading a new scenario
  doesn't inherit the previous one's baselines.

Also cache GetCurrentDisplayForProperty calls in RefreshDynamicPropertyValues:
the property panel was invoking the BP interface every tick per visible
combo. Now each row stores LastExportedValue + LastDisplayToShow and
short-circuits if the property's exported text is unchanged — BP only
runs on actual value transitions (seek, timeline keyframe, manual edit).
Duplicated across MainWidget and MainWidget_Legacy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:13:37 +02:00
bc491b7b94 Display-vs-value custom dropdown options + UI mode parity docs
Extend the EditableInterface with two new BlueprintNativeEvents so property
combos can show human-friendly labels while storing technical values:

- GetCustomOptionsWithValuesForProperty returns TArray<FPS_Editor_PropertyOption>
  where each pair carries a DisplayName (shown in the combo) and a Value
  (written to the property via ImportText). Wins over the legacy flat-string
  variant when non-empty. Ideal for TSubclassOf / TSoftClassPtr properties
  driven by a DataTable lookup where the stored value is a class path.
- GetCurrentDisplayForProperty lets the actor authoritatively resolve the
  DisplayName from the current stored value, for cases where the automatic
  reverse lookup (normalize class path, exact-match) isn't reliable — e.g.
  several Values collapse to the same label, or the format is user-specific.

UI refresh now asks the actor first, then falls back to a normalized match
(strips the `TypeName'...'` wrapper from ExportText output so it compares
apples to apples with a plain /Game/Path.X_C string).

Same logic duplicated across both widget implementations (MainWidget and
MainWidget_Legacy) since the project can switch UI mode at runtime. The new
CLAUDE.md rule makes that requirement explicit.

Also documented in CLAUDE.md:
- Editor-vs-gameplay execution boundary: which interface methods fire where,
  including the gotcha that OnEditorPropertyChanged *does* fire in gameplay
  on the server when the timeline animates a property.
- UI modes: keep all MainWidget* implementations in lockstep to avoid the
  "I edited the file but nothing changed" class of bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:43:40 +02:00
74 changed files with 3978 additions and 585 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.

110
CLAUDE.md
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)
@@ -10,6 +10,91 @@ Unreal Engine 5.5 project with runtime editor plugin (PS_Editor). The plugin ena
- **Serialization**: JSON via Json/JsonUtilities modules (FJsonObjectConverter)
- **Input**: Enhanced Input system (AZERTY: arrows + A/Q movement, E/R/T modes)
## Editor vs. Gameplay execution boundary — CRITICAL
The plugin runs in two distinct modes and it's essential never to leak editor-only code
into the gameplay runtime loaded via `UPS_Editor_SceneLoader::LoadScene` (standalone).
### Editor runtime mode
Activated when the `APS_Editor_PlayerController` + `APS_Editor_HUD` + `UPS_Editor_MainWidget*`
stack is created (typical during authoring — the user is in the runtime editor).
- `UPS_Editor_SceneLoader::bIsEditorModeActive == true`
- The HUD widget drives property editing, catalogue spawn, save/load, simulate, timeline
- Interface methods called **only** by the widget (never in gameplay):
- `GetCustomOptionsForProperty` / `GetCustomOptionsWithValuesForProperty` / `GetCurrentDisplayForProperty`
- `OnEditorModeChanged`, `OnEditorTick`
- `OnEditorNeedsReinit`, `OnEditorNeedsRespawn`
### Gameplay mode
The project (PROSERVE) loads a scenario via `UPS_Editor_SceneLoader::LoadScene` from a GameMode
BeginPlay, without ever instantiating the editor PlayerController / HUD / widget.
- `UPS_Editor_SceneLoader::bIsEditorModeActive == false`
- Actors spawn, their properties are applied from JSON, `OnPropertiesLoaded` fires once
- The timeline subsystem auto-plays if the scenario has tracks, driven **server-side only**
### Interface methods that fire in BOTH modes
- `OnPropertiesLoaded` — after every scene load, editor or gameplay (applied once post-spawn)
- `OnEditorPropertyChanged` — fires both in editor (on UI edit via `ApplyPropertyFromUI`) AND
in gameplay (on server when the timeline applies a new keyframe value via
`TimelineSubsystem::ApplyTrackAtTime`). Despite the name, the "Editor" refers to the
PS_Editor plugin — the event is the canonical hook for "a property changed at runtime".
In gameplay only the **server** invokes it; client-side reaction must go through
`ReplicatedUsing=OnRep_X` RepNotify on the replicated property.
### Rule when adding new features
- Anything tied to property panel UI (new interface method, new widget logic, dropdown
helpers, etc.) lives in the widget only — no extra work to scope it out of gameplay
- Anything added to the `SceneLoader::LoadScene` path runs in gameplay and must handle
`NM_Client`, replication, and server-authority correctly (the timeline subsystem is the
reference pattern)
- Expensive BP implementations of editor-UI interface methods (DataTable lookups, class
resolution, etc.) can stay expensive — they never run outside the authoring session
### How to guard code that could run in either mode
```cpp
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
The runtime editor ships with **multiple widget implementations** that must all stay in sync:
- `PS_Editor_MainWidget.cpp/.h` — modern UMG-based widget (primary target long-term)
- `PS_Editor_MainWidget_Legacy.cpp/.h` — Slate-only legacy widget (**currently active** as of April 2026)
- `PS_Editor_MainWidget_UMG.cpp/.h` — UMG variant
The user can switch between them via a `UIMode` setting. At any given time only one is running, but the project keeps all three compiled.
**Rule:** when changing any behavior inside a widget (new interface calls, new property row logic, new UI controls, bug fixes in RebuildDynamicProperties / ApplyPropertyFromUI / RefreshDynamicPropertyValues, etc.), **apply the change to ALL widget implementations at the same time**. Otherwise the user's current UIMode gets the fix and the others don't — the "it's like you didn't change anything" bug.
Symptom that the wrong widget was edited: logs you added don't appear in the Output Log despite the build being clean and the DLL being correctly loaded. Always grep for the function name across all `PS_Editor_MainWidget*.cpp` files before assuming a single edit is sufficient.
## Naming Conventions
- All plugin files and classes use the `PS_Editor_` prefix (e.g., `PS_Editor_GameMode`, `PS_Editor_Pawn`)
- Code and comments in English
@@ -21,15 +106,28 @@ Unreal Engine 5.5 project with runtime editor plugin (PS_Editor). The plugin ena
- When fixing a bug, fix the root cause, not the symptom.
- If something requires error handling or validation to work reliably, include it without asking.
## Workflow — CRITICAL
- **Do not make behavior / logic changes without first explaining the plan and waiting for the
user's go-ahead.** Explain what the change will be, why it fixes the issue, and what side
effects it could have. Only after the user approves (or asks to proceed), apply the edit.
- Diagnostic-only additions (temporary `UE_LOG`, read-only inspections) are fine without
approval because they can't regress the project.
- Refactors, algorithm changes, comportement changes, new defaults, removed features — all
require approval first, even when they seem obvious.
- When revealing new data (e.g. logs) suggests a fix, STATE the proposed fix in plain words
and wait. Don't pre-emptively write + apply the code change in the same turn.
## Build
- Engine: Unreal Engine 5.5
- Plugin location: `Unreal/Plugins/PS_Editor/`
- Game module: `PS_ProserveEditor` (in `Unreal/Source/PS_ProserveEditor/`)
- Engine: Unreal Engine 5.7 (host project migrated from 5.5 in April 2026)
- Plugin location: `Unreal/Plugins/PS_Editor/` (consumed via NTFS junction from the PROSERVE host project's `Plugins/` directory)
- Host project: `E:\ASTERION\SVN\DEV\PROSERVE_UE_5_7\PROSERVE_UE_5_7.uproject` — always build this, not the plugin in isolation
- Game module: `PROSERVE_UE_5_7` (in the host project's `Source/`)
- CLI build command (Editor, Development):
```
"<UE_INSTALL>/Engine/Build/BatchFiles/Build.bat" PS_ProserveEditorEditor Win64 Development -Project="<REPO>/Unreal/PS_ProserveEditor.uproject" -WaitMutex -FromMsBuild
"C:/Program Files/Epic Games/UE_5.7/Engine/Build/BatchFiles/Build.bat" PROSERVE_UE_5_7Editor Win64 Development -Project="E:/ASTERION/SVN/DEV/PROSERVE_UE_5_7/PROSERVE_UE_5_7.uproject" -WaitMutex -FromMsBuild
```
UE install path can be found via registry: `HKLM\SOFTWARE\EpicGames\Unreal Engine\5.5` > `InstalledDirectory`
UE install path can be found via registry: `HKLM\SOFTWARE\EpicGames\Unreal Engine\5.7` > `InstalledDirectory`
- The 5.5 host (`PROSERVE_UE_5_5`) is deprecated — do not build against it unless explicitly asked.
## Project Memory
Shared project memory is stored in `.claude/memory/` (versioned in the repository). When starting a new session on any machine, read these files to restore context:

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -2,6 +2,8 @@
#include "Camera/CameraComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Components/PostProcessComponent.h"
#include "Materials/MaterialInterface.h"
#include "UObject/ConstructorHelpers.h"
#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "InputAction.h"
@@ -10,6 +12,9 @@
#include "InputTriggers.h"
#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"
@@ -43,24 +48,27 @@ APS_Editor_Pawn::APS_Editor_Pawn()
OutlinePostProcess = CreateDefaultSubobject<UPostProcessComponent>(TEXT("OutlinePostProcess"));
OutlinePostProcess->SetupAttachment(RootScene);
OutlinePostProcess->bUnbound = true; // Apply to entire scene
// Hard-ref the outline material at CDO-construction time so the cooker follows it into
// packaged builds. Replaces the old runtime LoadObject<> soft-string path.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> OutlineMatFinder(
TEXT("/PS_Editor/M_PS_Editor_SelectionOutline.M_PS_Editor_SelectionOutline"));
if (OutlineMatFinder.Succeeded())
{
OutlineMaterial = OutlineMatFinder.Object;
}
}
void APS_Editor_Pawn::BeginPlay()
{
Super::BeginPlay();
// Load outline post-process material
UMaterialInterface* OutlineMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_SelectionOutline.M_PS_Editor_SelectionOutline"));
if (OutlineMat && OutlinePostProcess)
// OutlineMaterial is resolved in the ctor via ConstructorHelpers (hard cook ref).
if (OutlineMaterial && OutlinePostProcess)
{
OutlinePostProcess->Settings.WeightedBlendables.Array.Add(FWeightedBlendable(1.0f, OutlineMat));
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Selection outline material loaded"));
OutlinePostProcess->Settings.WeightedBlendables.Array.Add(FWeightedBlendable(1.0f, OutlineMaterial));
}
// TODO: Spline occluded PP material — disabled for now, depth comparison unreliable
// Consider translucent second-pass approach instead of post-process
else
else if (!OutlineMaterial)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Outline material /PS_Editor/M_PS_Editor_SelectionOutline not found. "
"Create a Post Process material in Plugins/PS_Editor/Content/"));
@@ -71,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);
@@ -223,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)
@@ -269,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 ----
@@ -442,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())
@@ -454,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
@@ -674,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())
@@ -753,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;
}
}
@@ -769,22 +828,18 @@ void APS_Editor_Pawn::HandleLeftClickStarted(const FInputActionValue& Value)
{
if (IPS_Editor_SplineEditable* Spline = Cast<IPS_Editor_SplineEditable>(EdPC3->GetActivePlacementActor()))
{
// Raycast to get world click position
FHitResult AddHit;
FCollisionQueryParams AddParams;
AddParams.AddIgnoredActor(this);
AddParams.AddIgnoredActor(Cast<AActor>(Spline));
const FVector AddEnd = RayOrigin + RayDir * 100000.0f;
// Project the click ray onto ground. Uses the shared helper so that a click
// pointing at the sky still lands the control point on the ground below the
// camera's forward vector (instead of floating 1000 units ahead).
TArray<AActor*> Ignored;
Ignored.Add(this);
if (AActor* SplineActor = Cast<AActor>(Spline)) Ignored.Add(SplineActor);
FVector ClickPos;
if (GetWorld()->LineTraceSingleByChannel(AddHit, RayOrigin, AddEnd, ECC_Camera, AddParams))
{
ClickPos = AddHit.ImpactPoint + FVector(0.0f, 0.0f, 5.0f);
}
else
{
ClickPos = RayOrigin + RayDir * 1000.0f;
}
FVector ClickPos = PS_Editor_GroundSnap::ProjectCameraRayToGround(
GetWorld(), RayOrigin, RayDir, Ignored);
// Lift the CP slightly above the surface so the tube mesh renders cleanly
// (otherwise Z-fighting with the ground).
ClickPos.Z += 5.0f;
TArray<FVector> OldPoints = Spline->GetAllPointLocations();
Spline->AddPoint(ClickPos);
@@ -813,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;
}
}
@@ -847,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()))
@@ -857,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)
@@ -866,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);
}
@@ -992,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"));
}
}
}
@@ -1005,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"));
}
}
}
@@ -1018,11 +1085,17 @@ 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"));
}
}
}
void APS_Editor_Pawn::ClearActiveSubElementDrag()
{
ActiveSplinePointIndex = -1;
DraggedSplineActor = nullptr;
}
void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
{
if (CameraMode != EPS_Editor_CameraMode::Idle) return;
@@ -1082,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)
{
@@ -1094,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));
@@ -1108,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())
{
@@ -1188,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();
@@ -1323,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)
@@ -1515,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()))
@@ -1524,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

@@ -11,6 +11,7 @@ class USpringArmComponent;
class UPostProcessComponent;
class UInputAction;
class UInputMappingContext;
class UMaterialInterface;
DECLARE_DELEGATE_TwoParams(FPS_Editor_OnClick, FVector2D /*ScreenPos*/, bool /*bAdditive*/);
@@ -68,6 +69,11 @@ public:
/** Currently selected spline point index (-1 = none). Used by UI for Delete Point button. */
int32 ActiveSplinePointIndex = -1;
/** Clear any active sub-element drag state (spline point / ChildMovable index + target actor).
* Call this when leaving placement / edit mode so a subsequent gizmo drag on the newly
* selected parent actor moves the whole actor instead of the last clicked point. */
void ClearActiveSubElementDrag();
protected:
virtual void BeginPlay() override;
@@ -85,6 +91,12 @@ protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<UPostProcessComponent> OutlinePostProcess;
/** Selection outline post-process material (from /PS_Editor/M_PS_Editor_SelectionOutline).
* Hard-ref so the cooker picks it up in packaged builds. Resolved via ConstructorHelpers
* in the ctor. */
UPROPERTY()
TObjectPtr<UMaterialInterface> OutlineMaterial;
// ---- Camera Settings ----
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")
@@ -197,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;
@@ -241,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

@@ -9,6 +9,9 @@
#include "PS_Editor_SplineActor.h"
#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"
@@ -28,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;
@@ -97,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();
@@ -169,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);
@@ -185,6 +240,17 @@ void APS_Editor_PlayerController::Tick(float DeltaTime)
{
SpawnManager->TickEditorMode(DeltaTime);
}
// Keep the gizmo glued to its selection every frame while simulating — otherwise
// animated actors (AI walking, timeline-driven properties, ragdoll) leave the gizmo
// behind at the pose it had when the actor was last selected / dragged. Outside of
// simulation the selection doesn't move on its own so the normal mutation-driven
// UpdateGizmo calls (click / drag / undo) are enough — and skipping this avoids any
// feedback with the pawn's in-progress gizmo drag.
if (bSimulating && SelectionManager && SelectionManager->IsAnythingSelected())
{
SelectionManager->UpdateGizmo();
}
}
void APS_Editor_PlayerController::OnPossess(APawn* InPawn)
@@ -237,6 +303,16 @@ void APS_Editor_PlayerController::CloseEditor()
void APS_Editor_PlayerController::StartSimulation()
{
// If a RequestStopSimulation is in flight, cancel it — user wants to resume.
if (UWorld* World = GetWorld())
{
if (PendingStopSimulationHandle.IsValid())
{
World->GetTimerManager().ClearTimer(PendingStopSimulationHandle);
PendingStopSimulationHandle.Invalidate();
}
}
if (bSimulating) return;
bSimulating = true;
@@ -253,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)
{
@@ -286,7 +399,18 @@ void APS_Editor_PlayerController::StartSimulation()
}
}
if (AAIController* AIC = Cast<AAIController>(P->GetController()))
AController* RawCtrl = P->GetController();
// Safety net: if auto-possession never fired (e.g. AutoPossessAI=Disabled, or
// pawn got its tick frozen before possession completed), spawn the default
// AIController now so the BT can run during simulation.
if (!RawCtrl && P->AIControllerClass)
{
P->SpawnDefaultController();
RawCtrl = P->GetController();
}
if (AAIController* AIC = Cast<AAIController>(RawCtrl))
{
AIC->SetActorTickEnabled(true);
if (UBrainComponent* Brain = AIC->GetBrainComponent())
@@ -298,11 +422,35 @@ void APS_Editor_PlayerController::StartSimulation()
}
}
// Broadcast OnEditorSimulateStart to every spawned actor that implements the editable
// interface — fired AFTER AI has been enabled so BP handlers can query the AI state.
if (SpawnManager)
{
for (const TWeakObjectPtr<AActor>& Weak : SpawnManager->GetSpawnedActors())
{
AActor* Actor = Weak.Get();
if (Actor && Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnEditorSimulateStart(Actor);
}
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Simulation started (%d actors saved)"), SimulationSavedTransforms.Num());
}
void APS_Editor_PlayerController::SetSimulationPaused(bool bPaused)
{
// Resuming? Cancel any in-flight deferred stop (user chose to keep going).
if (!bPaused && PendingStopSimulationHandle.IsValid())
{
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(PendingStopSimulationHandle);
PendingStopSimulationHandle.Invalidate();
}
}
// Toggle AI tick + BT on every spawned pawn without touching bSimulating or the saved
// transforms — the timeline's Pause uses this so characters freeze instead of snapping
// back to their pre-play positions.
@@ -332,9 +480,93 @@ void APS_Editor_PlayerController::SetSimulationPaused(bool bPaused)
}
}
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 (deferred) or run it now
// (immediate, used by timeline Restart).
if (SpawnManager)
{
for (const TWeakObjectPtr<AActor>& Weak : SpawnManager->GetSpawnedActors())
{
AActor* Actor = Weak.Get();
if (Actor && Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnEditorBeforeSimulateStop(Actor);
}
}
}
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);
World->GetTimerManager().SetTimer(PendingStopSimulationHandle,
[WeakThis]()
{
if (APS_Editor_PlayerController* Self = WeakThis.Get())
{
if (Self->IsSimulating())
{
Self->StopSimulation();
}
Self->PendingStopSimulationHandle.Invalidate();
}
},
0.5f, false);
}
else
{
// No world timer available (shouldn't happen in practice) — fall back to sync stop.
StopSimulation();
}
}
void APS_Editor_PlayerController::StopSimulation()
{
if (!bSimulating) return;
// Broadcast OnEditorSimulateStop to every spawned actor BEFORE we actually cut AI or
// restore transforms. At this point bSimulating is still true and the BT is still
// live — BP handlers can read world state, stop audio, log telemetry, etc.
if (SpawnManager)
{
for (const TWeakObjectPtr<AActor>& Weak : SpawnManager->GetSpawnedActors())
{
AActor* Actor = Weak.Get();
if (Actor && Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnEditorSimulateStop(Actor);
}
}
}
bSimulating = false;
// Disable AI logic and re-apply rotation/movement overrides on all spawned pawns
@@ -381,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)
@@ -395,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)
@@ -422,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()
@@ -444,13 +718,21 @@ void APS_Editor_PlayerController::FinishPointPlacement()
IPS_Editor_ChildMovable::Execute_ClearChildHighlight(PlacementActor);
}
// Clear the pawn's sub-element drag state (last clicked spline point / child index).
// Without this, grabbing the gizmo right after Done would move the last clicked point
// instead of the whole actor, because the drag logic keys on ActiveSplinePointIndex.
if (APS_Editor_Pawn* EditorPawn = Cast<APS_Editor_Pawn>(GetPawn()))
{
EditorPawn->ClearActiveSubElementDrag();
}
if (PlacementActor && SelectionManager)
{
SelectionManager->ClearSelection();
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;
@@ -491,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
@@ -611,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
{
@@ -659,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;
@@ -672,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
@@ -690,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)
{
@@ -799,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,48 +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. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
/** Start simulation: enable AI, store actor positions, disable saving.
* Cancels any in-flight deferred RequestStopSimulation. */
UFUNCTION(BlueprintCallable, Category = "PS_Editor")
void StartSimulation();
/** Stop simulation: disable AI, restore actor positions, re-enable saving. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
/** 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 = "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.
*
* @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; }
/**
@@ -79,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.
@@ -88,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. */
@@ -96,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. */
@@ -128,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;
@@ -165,6 +181,15 @@ 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;
/** Currently loaded sublevel instance. */
UPROPERTY()
TObjectPtr<ULevelStreamingDynamic> CurrentSublevel;

View File

@@ -26,6 +26,15 @@ APS_Editor_Gizmo::APS_Editor_Gizmo()
{
PrimaryActorTick.bCanEverTick = true;
// Hard-ref the gizmo base material at CDO-construction time so the cooker follows it
// into packaged builds. Replaces the old runtime LoadObject<> soft-string path.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> GizmoBaseMatFinder(
TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
if (GizmoBaseMatFinder.Succeeded())
{
GizmoBaseMaterial = GizmoBaseMatFinder.Object;
}
GizmoRoot = CreateDefaultSubobject<USceneComponent>(TEXT("GizmoRoot"));
SetRootComponent(GizmoRoot);
@@ -92,9 +101,8 @@ void APS_Editor_Gizmo::BeginPlay()
{
Super::BeginPlay();
// Load gizmo material
UMaterialInterface* GizmoBaseMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
// GizmoBaseMaterial is resolved in the ctor via ConstructorHelpers (hard cook ref).
UMaterialInterface* GizmoBaseMat = GizmoBaseMaterial.Get();
auto MakeColorMat = [&](const FLinearColor& Color) -> UMaterialInstanceDynamic*
{

View File

@@ -8,6 +8,7 @@
class UStaticMeshComponent;
class UProceduralMeshComponent;
class UMaterialInstanceDynamic;
class UMaterialInterface;
/**
* Custom runtime transform gizmo for PS_Editor.
@@ -109,6 +110,12 @@ private:
TObjectPtr<UStaticMeshComponent> ScaleCube_Center;
// ---- Materials ----
/** Base gizmo material (from /PS_Editor/M_PS_Editor_Gizmo). Hard-ref so the cooker
* picks it up in packaged builds. Resolved via ConstructorHelpers in the ctor. */
UPROPERTY()
TObjectPtr<UMaterialInterface> GizmoBaseMaterial;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_X;
UPROPERTY()

View File

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

View File

@@ -0,0 +1,206 @@
#include "PS_Editor_GroundSnap.h"
#include "PS_Editor_SpawnableComponent.h"
#include "Engine/World.h"
#include "GameFramework/Actor.h"
#include "CollisionQueryParams.h"
namespace PS_Editor_GroundSnap
{
// Normal.Z threshold that classifies a hit surface as "floor-like" (roughly walkable
// slope — cos(72°) ≈ 0.3). Below this, we treat the surface as a wall / ceiling /
// overhang and keep searching.
static constexpr float GFloorNormalThreshold = 0.3f;
/** Single downward trace that returns the first floor-like hit. Returns false if
* nothing below qualifies (all walls, ceiling-like geometry, etc.). */
static bool FindFloorBelow(UWorld* World, const FVector& Above, float Distance,
const FCollisionQueryParams& Params, FVector& OutImpact)
{
TArray<FHitResult> Hits;
if (!World->LineTraceMultiByChannel(Hits, Above, Above - FVector::UpVector * Distance,
ECC_Visibility, Params))
{
return false;
}
for (const FHitResult& H : Hits)
{
if (H.ImpactNormal.Z >= GFloorNormalThreshold)
{
OutImpact = H.ImpactPoint;
return true;
}
}
return false;
}
FVector ProjectCameraRayToGround(UWorld* World,
const FVector& Start,
const FVector& Direction,
const TArray<AActor*>& IgnoreActors,
float MaxCameraDistance,
float DefaultDistance,
float DownTraceDistance)
{
if (!World)
{
return Start + Direction * DefaultDistance;
}
FCollisionQueryParams Params(SCENE_QUERY_STAT(PS_Editor_GroundSnap), false);
for (AActor* A : IgnoreActors)
{
if (A) Params.AddIgnoredActor(A);
}
// 1) Primary camera-aligned trace.
FHitResult Hit;
if (World->LineTraceSingleByChannel(Hit, Start,
Start + Direction.GetSafeNormal() * MaxCameraDistance,
ECC_Visibility, Params))
{
const float NormalZ = Hit.ImpactNormal.Z;
// Ceiling / overhang: normal points DOWN. Re-cast downward from a point just
// BELOW the ceiling impact (not on the surface itself — avoids re-hitting the
// backface due to floating-point slop).
if (NormalZ <= -0.3f)
{
const FVector StartBelowCeiling = Hit.ImpactPoint - FVector::UpVector * 2.0f;
FVector FloorImpact;
if (FindFloorBelow(World, StartBelowCeiling, DownTraceDistance, Params, FloorImpact))
{
return FloorImpact;
}
return Hit.ImpactPoint;
}
// Wall: roughly horizontal normal. Step BACK along the wall normal (away from
// the surface) by a small distance so the placed object doesn't end up with its
// bbox half-buried in the wall, then cast down to find the floor at the foot
// of the wall. Without this, clicking on a wall drops the object inside it.
if (NormalZ < 0.3f) // not floor-like either
{
constexpr float WallStandoff = 30.0f;
const FVector StartAwayFromWall = Hit.ImpactPoint + Hit.ImpactNormal * WallStandoff;
FVector FloorImpact;
if (FindFloorBelow(World, StartAwayFromWall, DownTraceDistance, Params, FloorImpact))
{
return FloorImpact;
}
// No floor below — at least return the stood-off point so we're not inside
// the wall.
return StartAwayFromWall;
}
// Floor-like: use impact directly.
return Hit.ImpactPoint;
}
// 2) Camera missed. Aim DOWN from a fallback point in front of the camera so the
// object still lands on ground instead of hovering in the sky.
const FVector FallbackTarget = Start + Direction.GetSafeNormal() * DefaultDistance;
FVector FloorImpact;
if (FindFloorBelow(World, FallbackTarget, DownTraceDistance, Params, FloorImpact))
{
return FloorImpact;
}
// 3) Nothing at all — give up and return the in-front point.
return FallbackTarget;
}
bool SnapActorToGround(AActor* Actor,
FVector& OutLocation,
float OverheadRaise,
float MaxDistance,
const TArray<AActor*>& ExtraIgnored)
{
if (!Actor || !Actor->GetWorld())
{
return false;
}
// 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);
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 = bUsedFallback ? 0.0f : (ActorZ - BBoxBottomZ);
float ExtraOffset = 0.0f;
if (UPS_Editor_SpawnableComponent* SpawnComp = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>())
{
ExtraOffset = SpawnComp->GroundOffset;
}
// Start INSIDE the actor (at its pivot + a small buffer), not above its bbox top.
// Why: if we started above the bbox top and the actor was raised even slightly into
// a ceiling, the ray would cross the ceiling and the FIRST floor-like hit (filtered
// by normal.Z >= 0.3) would be the CEILING'S TOP FACE — i.e. the floor of the
// upper story. That made "End" teleport the actor up to the next story when the
// user only nudged it a bit too high.
//
// Starting at the pivot keeps the ray origin within the actor's own volume (pivot
// is always inside or at the edge of the bbox for normal meshes / characters). The
// actor is in the ignore list, so the trace passes straight through its own body
// and finds the first floor-like surface UNDERNEATH the actor — i.e. the floor of
// its current room.
//
// OverheadRaise is unused now (kept for API compat); the 2.0f buffer just avoids
// a degenerate "ray starts exactly on ground surface" edge case when the pivot
// coincides with the ground.
(void)BBoxTopZ; (void)OverheadRaise;
const FVector Start = Actor->GetActorLocation() + FVector(0.f, 0.f, 2.0f);
const FVector End = Start - FVector::UpVector * MaxDistance;
FCollisionQueryParams Params(SCENE_QUERY_STAT(PS_Editor_SnapActorToGround), false);
Params.AddIgnoredActor(Actor);
for (AActor* A : ExtraIgnored)
{
if (A) Params.AddIgnoredActor(A);
}
// Multi-hit trace + filter by normal so we only snap to floor-like surfaces.
// Accepts slopes up to ~72 degrees (normal.Z > 0.3). Rejects walls, ceilings,
// overhangs, and any backface we might cross on the way down.
TArray<FHitResult> Hits;
if (!Actor->GetWorld()->LineTraceMultiByChannel(Hits, Start, End, ECC_Visibility, Params))
{
return false;
}
constexpr float FloorNormalThreshold = 0.3f; // cos(~72 deg)
for (const FHitResult& H : Hits)
{
if (H.ImpactNormal.Z >= FloorNormalThreshold)
{
OutLocation = Actor->GetActorLocation();
OutLocation.Z = H.ImpactPoint.Z + PivotAboveBottom + ExtraOffset;
return true;
}
}
// Trace hit stuff but nothing floor-like — don't move the actor.
return false;
}
}

View File

@@ -0,0 +1,67 @@
#pragma once
#include "CoreMinimal.h"
class AActor;
class UWorld;
/**
* Ground-placement helpers used across catalogue spawn, End-key snap, and spline point
* placement. The goal is that every object the user drops / snaps ends up on the ground
* by default, even when the camera is pointing at the sky.
*/
namespace PS_Editor_GroundSnap
{
/**
* Project a camera-style ray onto ground.
*
* Sequence:
* 1. Line-trace from Start along Direction for up to MaxCameraDistance.
* - Hit a surface whose normal points UP / sideways -> return ImpactPoint.
* - Hit a surface whose normal points DOWN (ceiling, overhang) -> re-cast straight
* down from the impact point so the object lands on the floor underneath the
* overhead surface instead of glued to the ceiling.
* 2. Camera ray missed entirely (user aiming at the sky). Fall back to a point at
* DefaultDistance in front of the camera, then cast DOWN from there for
* DownTraceDistance. If THAT hits, return the downward impact.
* 3. Still nothing (infinite drop) -> return the in-front fallback unmodified so the
* caller at least gets a point in world space instead of a zero vector.
*
* @param IgnoreActors Actors the line trace must skip (typically the player pawn and,
* for re-snapping an existing actor, the actor itself — otherwise
* the trace hits its own mesh and nothing moves).
*/
PS_EDITOR_API FVector ProjectCameraRayToGround(UWorld* World,
const FVector& Start,
const FVector& Direction,
const TArray<AActor*>& IgnoreActors = {},
float MaxCameraDistance = 20000.0f,
float DefaultDistance = 1500.0f,
float DownTraceDistance = 50000.0f);
/**
* Compute a world position such that Actor's bounding-box bottom lands on the ground
* directly under its current XY position. Optionally adds a SpawnableComponent's
* GroundOffset for props that look better floating slightly (rugs, characters, etc.).
*
* Properly handles pivots not at bbox center (e.g. characters with pivot at feet) by
* measuring the pivot-to-bbox-bottom delta from GetActorBounds.
*
* The downward ray starts AT the actor's pivot (+ small buffer), not above its bbox.
* This keeps the trace origin within the current room (the pivot is always inside or
* on the edge of the actor's own volume) so the snap lands on the floor the actor is
* currently in, even when the actor has been nudged slightly into a ceiling. A multi-
* hit trace + a normal.Z >= 0.3 floor filter skips walls, ceilings, and backfaces
* encountered on the way down.
*
* OverheadRaise is kept for API compatibility but is no longer used — starting above
* the bbox was exactly what caused "End teleports to the upper story" bugs.
*
* @return true + OutLocation filled when ground was found; false when nothing is below.
*/
PS_EDITOR_API bool SnapActorToGround(AActor* Actor,
FVector& OutLocation,
float OverheadRaise = 50.0f,
float MaxDistance = 50000.0f,
const TArray<AActor*>& ExtraIgnored = {});
}

View File

@@ -3,6 +3,7 @@
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_GroundSnap.h"
#include "PS_Editor_EditableComponent.h"
#include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_SplineActor.h"
@@ -194,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)
@@ -234,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());
}
}
@@ -286,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);
}
}
@@ -448,6 +449,11 @@ void UPS_Editor_SelectionManager::SnapSelectionToGround()
TSharedPtr<FPS_Editor_TransformAction> UndoAction = MakeShared<FPS_Editor_TransformAction>();
UndoAction->Description = FString::Printf(TEXT("Snap to ground %d actor(s)"), SelectedActors.Num());
// Extra ignores shared across every actor in the batch.
TArray<AActor*> ExtraIgnored;
if (APawn* Pwn = PC->GetPawn()) ExtraIgnored.Add(Pwn);
if (Gizmo) ExtraIgnored.Add(Gizmo);
for (const TWeakObjectPtr<AActor>& Weak : SelectedActors)
{
AActor* Actor = Weak.Get();
@@ -456,36 +462,11 @@ void UPS_Editor_SelectionManager::SnapSelectionToGround()
UndoAction->Actors.Add(Actor);
UndoAction->OldTransforms.Add(Actor->GetActorTransform());
FVector Origin, Extent;
Actor->GetActorBounds(false, Origin, Extent);
float BottomOffset = Extent.Z;
// Add custom ground offset from SpawnableComponent if available
if (UPS_Editor_SpawnableComponent* SpawnComp = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>())
FVector NewLocation;
if (PS_Editor_GroundSnap::SnapActorToGround(Actor, NewLocation,
/*OverheadRaise*/ 50.0f, /*MaxDistance*/ 50000.0f, ExtraIgnored))
{
BottomOffset += SpawnComp->GroundOffset;
}
const FVector Start = Actor->GetActorLocation();
const FVector End = Start - FVector::UpVector * 100000.0f;
FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(Actor);
Params.AddIgnoredActor(PC->GetPawn());
if (Gizmo) Params.AddIgnoredActor(Gizmo);
if (Actor->GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Camera, Params))
{
FVector NewLocation = Hit.ImpactPoint;
NewLocation.Z += BottomOffset;
Actor->SetActorLocation(NewLocation);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: SnapToGround '%s' -> Hit '%s' (%s) at Z=%.1f, BottomOffset=%.1f, NewZ=%.1f"),
*Actor->GetName(),
*Hit.GetActor()->GetName(),
*Hit.GetComponent()->GetName(),
Hit.ImpactPoint.Z, BottomOffset, NewLocation.Z);
}
else
{

View File

@@ -106,6 +106,11 @@ public:
FPS_Editor_OnSelectionChanged OnSelectionChanged;
/** Update gizmo visibility, position, orientation, and axis constraints from the
* current selection. Called on every selection mutation, and per-tick during
* simulation so the gizmo tracks animated pawns. */
void UpdateGizmo();
private:
UPROPERTY()
TWeakObjectPtr<APlayerController> OwnerPC;
@@ -127,7 +132,4 @@ private:
/** Clean up any stale weak pointers in the selection array. */
void CleanupStaleReferences();
/** Update gizmo visibility and position based on current selection. */
void UpdateGizmo();
};

View File

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

@@ -4,6 +4,30 @@
#include "UObject/Interface.h"
#include "PS_Editor_EditableInterface.generated.h"
/**
* A single entry in a property's custom dropdown: what the user sees vs. what gets
* written into the property. Useful when the stored value is a class path, a tag,
* or any technical identifier and you want a human-friendly label in the combo.
*/
USTRUCT(BlueprintType)
struct FPS_Editor_PropertyOption
{
GENERATED_BODY()
/** Label shown in the runtime editor combo box. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS_Editor")
FString DisplayName;
/**
* Value written into the property when this option is selected (passed through
* FProperty::ImportText_InContainer). For a class-name FString, this would be
* 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 = "PS_Editor")
FString Value;
};
UINTERFACE(BlueprintType, meta = (DisplayName = "PS Editor Editable"))
class PS_EDITOR_API UPS_Editor_EditableInterface : public UInterface
{
@@ -27,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();
/**
@@ -49,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();
/**
@@ -57,24 +81,110 @@ 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);
/**
* Fired on every spawned actor once when the simulation starts — either via the
* Simulate button or the timeline Play button (which calls StartSimulation under
* 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 = "PS Editor|Editable")
void OnEditorSimulateStart();
/**
* Fired on every spawned actor once when the simulation is about to stop — either via
* the Simulate button, the Stop button, or after a natural end. Called BEFORE AI tick
* is actually cut and BEFORE transforms are restored, so the BT is still live and you
* can still read / write state. Use this for cleanup (stop audio, save telemetry, etc.).
*
* 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 = "PS Editor|Editable")
void OnEditorSimulateStop();
/**
* Fired synchronously on the Stop sequence, AFTER the timeline has restored the
* pre-animation baseline onto every track (so properties are back to their initial
* values and OnEditorPropertyChanged has already fired for each of them) but BEFORE
* the grace period that leads up to the actual AI cut.
*
* Use this hook to trigger transition animations, resume BT branches, stop in-flight
* actions — whatever reacts to the baseline being restored. You have ~500ms before
* OnEditorSimulateStop fires and the AI tick is disabled, so anything you start here
* has time to play out on-screen.
*
* Timing sequence:
* t=0 Stop() starts
* RestoreBaseline → ImportText + OnEditorPropertyChanged per track
* >>> OnEditorBeforeSimulateStop fires here <<<
* Grace period scheduled (500ms)
* t=500 OnEditorSimulateStop fires → AI tick cut → transforms restored
*
* Fires for ALL stop paths that go through RequestStopSimulation:
* - Timeline Stop / Restart (baseline restored first, then this event)
* - Simulate toolbar button when at least one timeline track exists
* - Simulate toolbar button without any timeline track (no baseline restore, but
* event still fires + grace period still granted)
* Does NOT fire for direct StopSimulation calls (rare — e.g. forced editor close).
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
void OnEditorBeforeSimulateStop();
/**
* Called every frame while in editor mode.
* Use this to update visuals dynamically based on property changes, animate helpers, etc.
* @param DeltaTime Time since last frame.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Editable")
void OnEditorTick(float DeltaTime);
/**
* Return custom dropdown options for a given property.
* If the returned array is non-empty, the editor shows a combo box with these options
* instead of the default widget for that property type.
* Return custom dropdown options for a given property (display == stored value).
* Simple form: the string shown in the combo is also the value written to the property.
* Use GetCustomOptionsWithValuesForProperty when display and storage need to differ.
*
* @param PropertyPath The property path (e.g. "WeaponName" or "MyComponent.WeaponType").
* @return List of option strings. Empty = use default widget.
* @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;
/**
* Structured variant of GetCustomOptionsForProperty: each entry carries a separate
* DisplayName (shown in the combo) and Value (written to the property). This is the
* version to use when the stored variable is a class name, a tag, a technical id —
* anything the user shouldn't have to read or type.
*
* Priority: if this returns a non-empty array, it wins over GetCustomOptionsForProperty.
*
* @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 = "PS Editor|Editable")
TArray<FPS_Editor_PropertyOption> GetCustomOptionsWithValuesForProperty(const FString& PropertyPath) const;
/**
* Return the DisplayName that should appear in the combo for the CURRENT value of the
* property. Use this when the runtime editor can't reliably reverse-lookup the display
* from the raw stored value (e.g. several Values collapse to the same DisplayName, or
* the Value format doesn't trivially match what you stored).
*
* The runtime editor calls this each frame while refreshing a row that has a custom-
* options combo. Typical Blueprint implementation: switch on PropertyPath, resolve the
* current variable (e.g. cast Self to your actor and read the Weapon variable), then
* search your DataTable / lookup for the matching DisplayName and return it.
*
* Return an empty string to let the default resolution kick in (exact match then
* normalized class-path match against the options returned by
* GetCustomOptionsWithValuesForProperty).
*
* @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 = "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

@@ -5,8 +5,13 @@
#include "PS_Editor_UndoManager.h"
#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"
@@ -90,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);
}
@@ -129,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)
@@ -159,27 +166,22 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector
SpawnedActors.Add(SpawnedActor);
RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor);
// Auto-snap to ground if configured
// 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())
{
FVector Origin, Extent;
SpawnedActor->GetActorBounds(false, Origin, Extent);
float BottomOffset = Extent.Z + SpawnComp->GroundOffset;
TArray<AActor*> ExtraIgnored;
if (APawn* Pwn = PC->GetPawn()) ExtraIgnored.Add(Pwn);
const FVector Start = SpawnedActor->GetActorLocation();
const FVector End = Start - FVector::UpVector * 100000.0f;
FHitResult Hit;
FCollisionQueryParams SnapParams;
SnapParams.AddIgnoredActor(SpawnedActor);
SnapParams.AddIgnoredActor(PC->GetPawn());
if (PC->GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Camera, SnapParams))
FVector SnappedLoc;
if (PS_Editor_GroundSnap::SnapActorToGround(SpawnedActor, SnappedLoc,
/*OverheadRaise*/ 50.0f, /*MaxDistance*/ 50000.0f, ExtraIgnored))
{
FVector NewLoc = Hit.ImpactPoint;
NewLoc.Z += BottomOffset;
SpawnedActor->SetActorLocation(NewLoc);
SpawnedActor->SetActorLocation(SnappedLoc);
}
}
@@ -273,23 +275,14 @@ FVector UPS_Editor_SpawnManager::ComputeSpawnLocation() const
FRotator CameraRotation;
PC->GetPlayerViewPoint(CameraLocation, CameraRotation);
const FVector Direction = CameraRotation.Vector();
const float MaxDistance = 5000.0f;
const float DefaultDistance = 500.0f;
const float SurfaceOffset = 5.0f;
// Unified placement: camera ray first, and if the camera is pointing at the sky / empty
// space, fall back to a downward trace from a point in front of the camera so the spawn
// still lands on ground. Avoids the "aim up, object hovers in the sky" bug.
TArray<AActor*> Ignored;
if (APawn* Pwn = PC->GetPawn()) Ignored.Add(Pwn);
FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(PC->GetPawn());
if (PC->GetWorld()->LineTraceSingleByChannel(Hit, CameraLocation, CameraLocation + Direction * MaxDistance, ECC_Visibility, Params))
{
// Hit something — place at impact point, slightly offset along the surface normal
return Hit.ImpactPoint + Hit.ImpactNormal * SurfaceOffset;
}
// Nothing hit — fallback to default distance
return CameraLocation + Direction * DefaultDistance;
return PS_Editor_GroundSnap::ProjectCameraRayToGround(PC->GetWorld(), CameraLocation,
CameraRotation.Vector(), Ignored);
}
AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Location, FRotator Rotation, FVector Scale)
@@ -318,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;
}
@@ -343,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);
}
}
}
}
}
}
@@ -430,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());
}
}
}
@@ -476,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)
@@ -487,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

@@ -5,7 +5,9 @@
#include "ProceduralMeshComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Materials/MaterialInterface.h"
#include "Materials/MaterialInstanceDynamic.h"
#include "UObject/ConstructorHelpers.h"
APS_Editor_SplineActor::APS_Editor_SplineActor()
{
@@ -50,25 +52,46 @@ APS_Editor_SplineActor::APS_Editor_SplineActor()
EditableComp->EditableProperties.Add({FName(TEXT("SplineColor"))});
EditableComp->EditableProperties.Add({FName(TEXT("SplineSelectedColor"))});
EditableComp->EditableProperties.Add({FName(TEXT("SplineTag"))});
// Hard-ref plugin + engine assets at CDO-construction time so the cooker follows them
// into packaged builds. Replaces the old runtime LoadObject<> soft-string paths.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> GizmoBaseMatFinder(
TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
if (GizmoBaseMatFinder.Succeeded())
{
GizmoBaseMaterial = GizmoBaseMatFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UMaterialInterface> SplineLineMatFinder(
TEXT("/PS_Editor/M_PS_Editor_SplineLine.M_PS_Editor_SplineLine"));
if (SplineLineMatFinder.Succeeded())
{
SplineLineMaterial = SplineLineMatFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UStaticMesh> CubeFinder(
TEXT("/Engine/BasicShapes/Cube.Cube"));
if (CubeFinder.Succeeded())
{
CenterCubeMesh = CubeFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UStaticMesh> SphereFinder(
TEXT("/Engine/BasicShapes/Sphere.Sphere"));
if (SphereFinder.Succeeded())
{
HandleSphereMesh = SphereFinder.Object;
}
}
void APS_Editor_SplineActor::BeginPlay()
{
Super::BeginPlay();
// Gizmo material (no depth test, renders on top) for handles and selected state
UMaterialInterface* GizmoMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
// Auto-generated opaque unlit material (depth tested) for spline line
UMaterialInterface* SplineLineMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_SplineLine.M_PS_Editor_SplineLine"));
if (!SplineLineMat)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: M_PS_Editor_SplineLine not found, trying without double name..."));
SplineLineMat = LoadObject<UMaterialInterface>(nullptr, TEXT("/PS_Editor/M_PS_Editor_SplineLine"));
}
// GizmoBaseMaterial / SplineLineMaterial / meshes are resolved in the ctor via
// ConstructorHelpers — hard cook references, available here.
UMaterialInterface* GizmoMat = GizmoBaseMaterial.Get();
UMaterialInterface* SplineLineMat = SplineLineMaterial.Get();
// Spline line: normal depth-tested material (uses SplineColor property)
if (SplineLineMat)
@@ -102,10 +125,9 @@ void APS_Editor_SplineActor::BeginPlay()
CenterCube = NewObject<UStaticMeshComponent>(this);
CenterCube->SetupAttachment(GetRootComponent());
CenterCube->RegisterComponent();
UStaticMesh* CubeMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube"));
if (CubeMesh)
if (CenterCubeMesh)
{
CenterCube->SetStaticMesh(CubeMesh);
CenterCube->SetStaticMesh(CenterCubeMesh);
}
CenterCube->SetWorldScale3D(FVector(CenterCubeScale));
CenterCube->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
@@ -310,11 +332,10 @@ UStaticMeshComponent* APS_Editor_SplineActor::CreatePointHandle(const FVector& W
Handle->SetupAttachment(HandleRoot);
Handle->RegisterComponent();
// Load sphere mesh
UStaticMesh* SphereMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Sphere.Sphere"));
if (SphereMesh)
// HandleSphereMesh resolved via ConstructorHelpers (hard cook ref).
if (HandleSphereMesh)
{
Handle->SetStaticMesh(SphereMesh);
Handle->SetStaticMesh(HandleSphereMesh);
}
Handle->SetWorldLocation(WorldPosition);

View File

@@ -10,6 +10,8 @@ class USplineComponent;
class UProceduralMeshComponent;
class UPS_Editor_SpawnableComponent;
class UPS_Editor_EditableComponent;
class UMaterialInterface;
class UStaticMesh;
/**
* Runtime spline actor for path/route editing in PS_Editor.
@@ -29,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). */
@@ -73,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 ----
@@ -166,6 +168,23 @@ private:
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> CenterCubeMat;
// ---- Hard-ref source assets (cooked into packaged builds via ConstructorHelpers) ----
/** Gizmo base material for spline handles / selected state (renders on top). */
UPROPERTY()
TObjectPtr<UMaterialInterface> GizmoBaseMaterial;
/** Depth-tested material for the normal spline line. */
UPROPERTY()
TObjectPtr<UMaterialInterface> SplineLineMaterial;
/** Engine basic shapes used for the center cube and point handles. */
UPROPERTY()
TObjectPtr<UStaticMesh> CenterCubeMesh;
UPROPERTY()
TObjectPtr<UStaticMesh> HandleSphereMesh;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UPS_Editor_SpawnableComponent> SpawnableComp;

View File

@@ -3,6 +3,7 @@
#include "PS_Editor_EditableComponent.h"
#include "PS_Editor_EditableInterface.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SpawnManager.h"
#include "Engine/World.h"
#include "Components/ActorComponent.h"
#include "GameFramework/Actor.h"
@@ -87,11 +88,21 @@ bool UPS_Editor_TimelineSubsystem::IsTickable() const
void UPS_Editor_TimelineSubsystem::Play()
{
if (Data.Tracks.Num() == 0) return;
// NOTE: do NOT early-return when Data.Tracks.Num() == 0 — the timeline Play button is
// the only user-facing way to start simulation now (the old toolbar Simulate button was
// removed), so it must drive StartSimulation unconditionally. An empty timeline still
// runs the sim lifecycle (Start/BeforeStop/Stop events fire), it just doesn't animate
// any property.
if (CurrentTime >= Data.Duration)
{
CurrentTime = 0.f;
}
// Make sure every track has a captured baseline. New tracks typically get their baseline
// at creation time (in AddOrUpdateKeyAtCurrentTime), tracks loaded from a scenario get
// theirs at SetData — this call is a safety net for anything that slipped through.
CaptureBaselinesForAllMissingTracks();
bIsPlaying = true;
// Timeline and simulation are inherently linked: start simulation if it isn't running,
@@ -135,25 +146,156 @@ void UPS_Editor_TimelineSubsystem::Stop()
LastAppliedValues.Empty();
LastAppliedValues.SetNum(Data.Tracks.Num());
// Full reset: stop simulation (restores pre-play transforms) and apply t=0 values so
// the scene visually matches the timeline start.
// Restore pre-play baseline values onto the actors BEFORE asking the PC to stop sim.
// This fires OnEditorPropertyChanged per track so the BT sees the restored values
// while it's still live (during the grace period that RequestStopSimulation sets up).
RestoreBaseline();
// Delegate to the PlayerController: it fires OnEditorBeforeSimulateStop, waits for its
// grace period, then runs the actual StopSimulation (which in turn fires the final
// OnEditorSimulateStop and cuts AI). Same sequence the Simulate toolbar button uses.
if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
{
if (EditorPC->IsSimulating())
{
EditorPC->StopSimulation();
EditorPC->RequestStopSimulation();
}
}
ApplyValuesAtTime(CurrentTime);
OnTimelineChanged.Broadcast();
}
void UPS_Editor_TimelineSubsystem::CaptureBaselineForTrack(int32 TrackIndex)
{
if (!Data.Tracks.IsValidIndex(TrackIndex)) return;
const FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex];
const TPair<FGuid, FName> Key(Track.ActorId, Track.PropertyPath);
if (PrePlayBaseline.Contains(Key)) return; // already captured
AActor* Actor = FindActorById(Track.ActorId);
if (!Actor) return;
UObject* Target = nullptr;
FProperty* Prop = nullptr;
ResolveTarget(Actor, Track.PropertyPath, Target, Prop);
if (!Target || !Prop) return;
FString Value;
Prop->ExportText_InContainer(0, Value, Target, Target, nullptr, PPF_None);
PrePlayBaseline.Add(Key, MoveTemp(Value));
}
void UPS_Editor_TimelineSubsystem::CaptureBaselinesForAllMissingTracks()
{
for (int32 i = 0; i < Data.Tracks.Num(); ++i)
{
CaptureBaselineForTrack(i);
}
}
void UPS_Editor_TimelineSubsystem::RestoreBaseline()
{
// 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)
{
AActor* Actor = FindActorById(Track.ActorId);
if (!Actor) continue;
UObject* Target = nullptr;
FProperty* Prop = nullptr;
ResolveTarget(Actor, Track.PropertyPath, Target, Prop);
if (!Target || !Prop) continue;
// 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();
}
Actor->ForceNetUpdate();
if (!Prop->RepNotifyFunc.IsNone())
{
if (UFunction* RepFunc = Target->FindFunction(Prop->RepNotifyFunc))
{
Target->ProcessEvent(RepFunc, nullptr);
}
}
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
IPS_Editor_EditableInterface::Execute_OnEditorPropertyChanged(Actor, Track.PropertyPath.ToString());
}
}
// 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();
}
@@ -194,6 +336,13 @@ void UPS_Editor_TimelineSubsystem::SetData(const FPS_Editor_TimelineData& NewDat
bIsPlaying = false;
LastAppliedValues.Empty();
LastAppliedValues.SetNum(Data.Tracks.Num());
// Drop the previous scenario's baselines and capture fresh ones from the actors that
// were just spawned by the scene loader. Their current values are by definition the
// pre-animation state for this scenario's tracks.
PrePlayBaseline.Reset();
CaptureBaselinesForAllMissingTracks();
OnTimelineChanged.Broadcast();
}
@@ -203,6 +352,11 @@ void UPS_Editor_TimelineSubsystem::Clear()
CurrentTime = 0.f;
bIsPlaying = false;
LastAppliedValues.Empty();
PrePlayBaseline.Reset();
// Drop the old ActorIds — the previous scenario's actors have been destroyed and the
// weak references are now stale. Keeping them around confuses FindActorById when the
// next scenario happens to reuse the same FGuid (it can return a stale null entry).
ActorIds.Reset();
OnTimelineChanged.Broadcast();
}
@@ -220,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;
@@ -227,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
@@ -248,6 +410,10 @@ bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, co
NewTrack.PropertyPath = PropertyPath;
TrackIndex = Data.Tracks.Add(NewTrack);
LastAppliedValues.SetNum(Data.Tracks.Num());
// Capture the pre-animation baseline NOW (before any key is added): the current actor
// value is the canonical "before timeline existed" state for this property.
CaptureBaselineForTrack(TrackIndex);
}
FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex];
@@ -259,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;
}
@@ -299,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();
}
@@ -308,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();
}
@@ -323,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();
}
@@ -469,11 +657,18 @@ FGuid UPS_Editor_TimelineSubsystem::FindActorId(AActor* Actor) const
AActor* UPS_Editor_TimelineSubsystem::FindActorById(const FGuid& Id) const
{
if (!Id.IsValid()) return nullptr;
// Iterate every pair: the map can legitimately contain stale weak references from a
// previous scenario whose actors were destroyed. Without this check the first match on
// GUID can return a null key before we reach the live entry, giving "(missing)" after
// the second load of the same scenario.
for (const TPair<TWeakObjectPtr<AActor>, FGuid>& Pair : ActorIds)
{
if (Pair.Value == Id)
{
return Pair.Key.Get();
if (AActor* Live = Pair.Key.Get())
{
return Live;
}
}
}
return nullptr;
@@ -482,6 +677,16 @@ AActor* UPS_Editor_TimelineSubsystem::FindActorById(const FGuid& Id) const
void UPS_Editor_TimelineSubsystem::RegisterActorId(AActor* Actor, const FGuid& Id)
{
if (!Actor || !Id.IsValid()) return;
// Prune any stale entries plus any previous entry holding the same GUID (the old actor
// has been destroyed and replaced by this one). Keeps the map tight and ensures
// FindActorById cannot hit a ghost key with the same GUID.
for (auto It = ActorIds.CreateIterator(); It; ++It)
{
if (!It.Key().IsValid() || It.Value() == Id)
{
It.RemoveCurrent();
}
}
ActorIds.Add(TWeakObjectPtr<AActor>(Actor), Id);
}

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.
@@ -149,6 +154,13 @@ private:
/** Cache of last applied exported-text value per track, used to avoid re-firing OnEditorPropertyChanged every frame when the value hasn't actually changed. */
TArray<FString> LastAppliedValues;
/** Runtime-only pre-animation baseline per track, keyed by (ActorId, PropertyPath) so it
* survives track re-ordering and isn't persisted to JSON (avoids stale data in legacy
* scenarios). Captured exactly once per track — at track creation (first keyframe) or
* at SetData time for tracks loaded from a scenario. Stop() reads from here; SetData
* and Clear reset it. */
TMap<TPair<FGuid, FName>, FString> PrePlayBaseline;
/** Map of stable IDs for every actor that participates in the timeline / scene registry. */
TMap<TWeakObjectPtr<AActor>, FGuid> ActorIds;
@@ -160,4 +172,16 @@ private:
/** Resolve the UObject target (actor or component) and the FProperty pointer. Nullable. */
static void ResolveTarget(AActor* Actor, const FName& PropertyPath, UObject*& OutTarget, FProperty*& OutProperty);
/** Capture the current actor value as the baseline for the given track, unless already
* captured. Called at track creation and at SetData (for tracks loaded from JSON). */
void CaptureBaselineForTrack(int32 TrackIndex);
/** Walk every track and call CaptureBaselineForTrack if it doesn't already have an entry. */
void CaptureBaselinesForAllMissingTracks();
/** Write baseline values back onto the actors + fire OnEditorPropertyChanged / RepNotify
* per track. No-op for tracks without a captured baseline. Does NOT clear the baseline
* map so subsequent Stops still work. */
void RestoreBaseline();
};

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())
{
@@ -1079,74 +1337,6 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.ButtonStyle(StyleFlatButton.Get())
.ToolTipText(FText::FromString(TEXT("Start/stop simulation. If a timeline exists, also plays/pauses it in sync.")))
.OnClicked_Lambda([this]() -> FReply
{
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
if (!EditorPC) return FReply::Handled();
UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this);
// If there's a timeline with at least one track, route through the timeline
// so simulate + animation are always in lockstep (Simulate is effectively
// the Play/Pause button of the timeline).
if (TS && TS->GetNumTracks() > 0)
{
if (TS->IsPlaying())
{
TS->Pause();
}
else
{
TS->Play();
}
}
else
{
// No animation to sync with — just toggle simulation directly.
if (EditorPC->IsSimulating())
{
EditorPC->StopSimulation();
}
else
{
EditorPC->StartSimulation();
}
}
return FReply::Handled();
})
[
SAssignNew(SimulateButtonText, STextBlock)
.Text(FText::FromString(TEXT("Simulate")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 10))
.ColorAndOpacity(FLinearColor(1.0f, 0.6f, 0.0f))
]
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.ButtonStyle(StyleFlatButton.Get())
.ToolTipText(FText::FromString(TEXT("Show/hide the timeline panel")))
.OnClicked_Lambda([this]() -> FReply
{
ToggleTimelineVisible();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Timeline")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 10))
.ColorAndOpacity(FLinearColor(0.6f, 0.8f, 1.0f))
]
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.ButtonStyle(StyleFlatButton.Get())
@@ -1227,7 +1417,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
}
}
SceneSerializer->LoadScene(FileName, SpawnManager.Get());
SceneSerializer->LoadScenario(FileName, SpawnManager.Get());
bSceneDirty = false;
if (SaveNameField.IsValid())
{
@@ -1486,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;
}
}
@@ -1532,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())
{
@@ -1616,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)
{
@@ -1816,30 +2006,7 @@ void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDelt
}
}
// Update simulate button text / color. Keep the label "Simulate" / "Stop" regardless of
// the timeline state so it doesn't clash with the dedicated Play button in the timeline
// panel — a second "Play" on the toolbar was confusing. The button still drives the
// timeline under the hood (see BuildToolbar lambda).
if (SimulateButtonText.IsValid())
{
if (APS_Editor_PlayerController* SimPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this);
const bool bTimelinePlaying = TS && TS->GetNumTracks() > 0 && TS->IsPlaying();
const bool bActive = SimPC->IsSimulating() || bTimelinePlaying;
if (bActive)
{
SimulateButtonText->SetText(FText::FromString(TEXT("Stop")));
SimulateButtonText->SetColorAndOpacity(FLinearColor(1.0f, 0.3f, 0.2f));
}
else
{
SimulateButtonText->SetText(FText::FromString(TEXT("Simulate")));
SimulateButtonText->SetColorAndOpacity(FLinearColor(1.0f, 0.6f, 0.0f));
}
}
}
// (Simulate toolbar button removed — Play/Pause/Restart/Stop live in the timeline panel.)
RefreshPropertiesFromSelection();
RefreshOutliner();
@@ -1912,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")));
}
@@ -1926,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)
@@ -2334,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);
@@ -2362,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)
{
@@ -2384,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
@@ -2400,12 +2588,27 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
// Value widget depends on property type
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
// Check for custom dropdown options from the interface
// Check for custom dropdown options from the interface. Structured variant
// (display + value) wins; fall back to the flat-string variant if it returns empty.
TArray<FString> CustomOptions;
Row.CustomOptionDisplayToValue.Empty();
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
TArray<FPS_Editor_PropertyOption> CustomOptionsPairs =
IPS_Editor_EditableInterface::Execute_GetCustomOptionsWithValuesForProperty(Actor, Row.Key);
if (CustomOptionsPairs.Num() > 0)
{
for (const FPS_Editor_PropertyOption& Pair : CustomOptionsPairs)
{
CustomOptions.Add(Pair.DisplayName);
Row.CustomOptionDisplayToValue.Add(Pair.DisplayName, Pair.Value);
}
}
else
{
CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key);
}
}
if (CustomOptions.Num() > 0)
{
@@ -2621,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()
@@ -2648,17 +2851,64 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
FString CurrentVal;
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != CurrentVal)
// Cache hit: the exported value hasn't changed since the last refresh, so the
// resolved DisplayName is identical too — skip the BP call + match loop entirely.
FString DisplayToShow;
if (!Row.LastExportedValue.IsEmpty() && CurrentVal == Row.LastExportedValue)
{
DisplayToShow = Row.LastDisplayToShow;
}
else
{
// Resolve the DisplayName to show in the combo. Priority order:
// 1. Ask the actor directly via GetCurrentDisplayForProperty — the user knows
// their storage format best, so this is authoritative when overridden.
// 2. Otherwise, fall back to matching CurrentVal / normalized-path against
// the options returned by GetCustomOptionsWithValuesForProperty.
DisplayToShow = CurrentVal;
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
const FString FromActor = IPS_Editor_EditableInterface::Execute_GetCurrentDisplayForProperty(Actor, Row.Key);
if (!FromActor.IsEmpty())
{
DisplayToShow = FromActor;
}
}
if (DisplayToShow == CurrentVal && Row.CustomOptionDisplayToValue.Num() > 0)
{
// Normalize class-ref format `TypeName'/Path.X_C'` → `/Path.X_C`.
FString NormalizedCurrent = CurrentVal;
int32 FirstQuote = INDEX_NONE;
int32 LastQuote = INDEX_NONE;
if (CurrentVal.FindChar(TEXT('\''), FirstQuote) && CurrentVal.FindLastChar(TEXT('\''), LastQuote)
&& LastQuote > FirstQuote + 1)
{
NormalizedCurrent = CurrentVal.Mid(FirstQuote + 1, LastQuote - FirstQuote - 1);
}
for (const TPair<FString, FString>& P : Row.CustomOptionDisplayToValue)
{
if (!P.Value.IsEmpty() && (P.Value == CurrentVal || P.Value == NormalizedCurrent))
{
DisplayToShow = P.Key;
break;
}
}
}
Row.LastExportedValue = CurrentVal;
Row.LastDisplayToShow = DisplayToShow;
}
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != DisplayToShow)
{
// Find matching option
if (Row.EnumOptions.IsValid())
{
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
{
if (*Opt == CurrentVal)
if (*Opt == DisplayToShow)
{
Row.EnumCombo->SetSelectedItem(Opt);
Row.EnumComboText->SetText(FText::FromString(CurrentVal));
Row.EnumComboText->SetText(FText::FromString(DisplayToShow));
break;
}
}
@@ -2804,8 +3054,25 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
{
// Custom options combo: use selected text directly as property value
NewValueText = Row.EnumComboText->GetText().ToString();
// Custom options combo. If we have a display->value map (structured variant),
// translate the selected display name into its stored value; otherwise fall back
// to the flat-string behavior where display and value are the same.
const FString Display = Row.EnumComboText->GetText().ToString();
if (Row.CustomOptionDisplayToValue.Num() > 0)
{
if (const FString* Mapped = Row.CustomOptionDisplayToValue.Find(Display))
{
NewValueText = *Mapped;
}
else
{
NewValueText = Display;
}
}
else
{
NewValueText = Display;
}
}
else if (Row.CachedEnum && Row.EnumComboText.IsValid())
{
@@ -2887,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());
}
}
@@ -2914,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());
@@ -3194,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())
@@ -3491,7 +3842,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildTimelinePanel()
const FSlateFontInfo Bold = FPS_Editor_UIStyle::BodyFontBold(S);
return SAssignNew(TimelinePanel, SBorder)
.Visibility(EVisibility::Collapsed)
.Visibility(EVisibility::Visible)
.BorderImage(BrushGlassStrong.Get())
.Padding(FMargin(12.0f, 8.0f))
[
@@ -3856,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)
{
@@ -4187,7 +4538,9 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildTopbar()
{
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (PC->IsSimulating()) PC->StopSimulation();
// Route through RequestStopSimulation so OnEditorBeforeSimulateStop
// fires + BT gets its grace period before AI is cut.
if (PC->IsSimulating()) PC->RequestStopSimulation();
else PC->StartSimulation();
}
return FReply::Handled();
@@ -4228,7 +4581,9 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildTopbar()
{
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (PC->IsSimulating()) PC->StopSimulation();
// Route through RequestStopSimulation so OnEditorBeforeSimulateStop
// fires + BT gets its grace period before AI is cut.
if (PC->IsSimulating()) PC->RequestStopSimulation();
else PC->StartSimulation();
}
return FReply::Handled();
@@ -4311,18 +4666,6 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildTopbar()
FPS_Editor_UIStyle::MakeIconFollowForeground(S, EPS_Editor_Icon::Sidebar, 14.0f)
]
]
// Timeline toggle
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(2.0f, 0.0f)
[
SNew(SButton)
.ButtonStyle(StyleFlatButton.Get())
.ContentPadding(FMargin(6.0f, 3.0f))
.ToolTipText(FText::FromString(TEXT("Toggle timeline")))
.OnClicked_Lambda([this]() -> FReply { ToggleTimelineVisible(); return FReply::Handled(); })
[
FPS_Editor_UIStyle::MakeIconFollowForeground(S, EPS_Editor_Icon::PanelBottom, 14.0f)
]
]
// Inspector (panel right) toggle
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(2.0f, 0.0f)
[
@@ -4874,7 +5217,8 @@ void UPS_Editor_MainWidget::ExecuteCommand(const FString& CommandId)
{
if (PC)
{
if (PC->IsSimulating()) PC->StopSimulation(); else PC->StartSimulation();
// Route through RequestStopSimulation so OnEditorBeforeSimulateStop fires + BT grace.
if (PC->IsSimulating()) PC->RequestStopSimulation(); else PC->StartSimulation();
}
}
else if (CommandId == TEXT("timeline")) { ToggleTimelineVisible(); }

View File

@@ -31,6 +31,19 @@ struct FPS_Editor_PropertyRowWidgets
TSharedPtr<STextBlock> EnumComboText;
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
UEnum* CachedEnum = nullptr;
/**
* DisplayName -> stored Value mapping for rows whose combo was populated by
* GetCustomOptionsWithValuesForProperty. Empty when the row is a flat-string combo
* (display == value) or a native enum combo.
*/
TMap<FString, FString> CustomOptionDisplayToValue;
/** Last exported text value applied to this row's combo; used to avoid recomputing
* the display name (and calling GetCurrentDisplayForProperty on the actor) each
* frame when the property hasn't actually changed. */
FString LastExportedValue;
FString LastDisplayToShow;
};
/**
@@ -94,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;
@@ -121,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;
@@ -142,9 +172,6 @@ private:
TSharedPtr<SWidget> SplineDeletePointButton;
TSharedPtr<SWidget> ChildEditButton;
// Simulate button
TSharedPtr<STextBlock> SimulateButtonText;
// Timeline panel
TSharedPtr<SWidget> TimelinePanel;
TSharedPtr<class SSlider> TimelineSlider;
@@ -152,7 +179,7 @@ private:
TSharedPtr<STextBlock> TimelinePlayButtonText;
TSharedPtr<SEditableTextBox> TimelineDurationBox;
TSharedPtr<SVerticalBox> TimelineTracksContainer;
bool bTimelineVisible = false;
bool bTimelineVisible = true;
FDelegateHandle TimelineChangedHandle;
/** Currently selected timeline key (updated on click). -1 means no selection. */
int32 SelectedTimelineTrackIdx = -1;

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,
@@ -179,7 +181,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::RebuildWidget()
BuildOutliner()
]
]
// Timeline panel (hidden by default; toggled via toolbar button)
// Timeline panel (always visible — toolbar toggle removed)
+ SOverlay::Slot()
.HAlign(HAlign_Fill)
.VAlign(VAlign_Bottom)
@@ -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())
{
@@ -907,72 +1177,6 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildSceneButtons()
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.ToolTipText(FText::FromString(TEXT("Start/stop simulation. If a timeline exists, also plays/pauses it in sync.")))
.OnClicked_Lambda([this]() -> FReply
{
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer());
if (!EditorPC) return FReply::Handled();
UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this);
// If there's a timeline with at least one track, route through the timeline
// so simulate + animation are always in lockstep (Simulate is effectively
// the Play/Pause button of the timeline).
if (TS && TS->GetNumTracks() > 0)
{
if (TS->IsPlaying())
{
TS->Pause();
}
else
{
TS->Play();
}
}
else
{
// No animation to sync with — just toggle simulation directly.
if (EditorPC->IsSimulating())
{
EditorPC->StopSimulation();
}
else
{
EditorPC->StartSimulation();
}
}
return FReply::Handled();
})
[
SAssignNew(SimulateButtonText, STextBlock)
.Text(FText::FromString(TEXT("Simulate")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 10))
.ColorAndOpacity(FLinearColor(1.0f, 0.6f, 0.0f))
]
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.ToolTipText(FText::FromString(TEXT("Show/hide the timeline panel")))
.OnClicked_Lambda([this]() -> FReply
{
ToggleTimelineVisible();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Timeline")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 10))
.ColorAndOpacity(FLinearColor(0.6f, 0.8f, 1.0f))
]
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
@@ -1052,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())
{
@@ -1305,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;
}
}
@@ -1351,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())
{
@@ -1435,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)
{
@@ -1566,30 +1770,7 @@ void UPS_Editor_MainWidget_Legacy::NativeTick(const FGeometry& MyGeometry, float
}
}
// Update simulate button text / color. Keep the label "Simulate" / "Stop" regardless of
// the timeline state so it doesn't clash with the dedicated Play button in the timeline
// panel — a second "Play" on the toolbar was confusing. The button still drives the
// timeline under the hood (see BuildToolbar lambda).
if (SimulateButtonText.IsValid())
{
if (APS_Editor_PlayerController* SimPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
UPS_Editor_TimelineSubsystem* TS = GetTimelineSubsystemFromWidget(this);
const bool bTimelinePlaying = TS && TS->GetNumTracks() > 0 && TS->IsPlaying();
const bool bActive = SimPC->IsSimulating() || bTimelinePlaying;
if (bActive)
{
SimulateButtonText->SetText(FText::FromString(TEXT("Stop")));
SimulateButtonText->SetColorAndOpacity(FLinearColor(1.0f, 0.3f, 0.2f));
}
else
{
SimulateButtonText->SetText(FText::FromString(TEXT("Simulate")));
SimulateButtonText->SetColorAndOpacity(FLinearColor(1.0f, 0.6f, 0.0f));
}
}
}
// (Simulate toolbar button removed — Play/Pause/Restart/Stop live in the timeline panel.)
RefreshPropertiesFromSelection();
RefreshOutliner();
@@ -1662,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")));
}
@@ -1676,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)
@@ -1997,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);
@@ -2025,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)
{
@@ -2047,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
@@ -2063,12 +2262,27 @@ void UPS_Editor_MainWidget_Legacy::RebuildDynamicProperties()
// Value widget depends on property type
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
// Check for custom dropdown options from the interface
// Check for custom dropdown options from the interface. Structured variant
// (display + value) wins; fall back to the flat-string variant if it returns empty.
TArray<FString> CustomOptions;
Row.CustomOptionDisplayToValue.Empty();
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
TArray<FPS_Editor_PropertyOption> CustomOptionsPairs =
IPS_Editor_EditableInterface::Execute_GetCustomOptionsWithValuesForProperty(Actor, Row.Key);
if (CustomOptionsPairs.Num() > 0)
{
for (const FPS_Editor_PropertyOption& Pair : CustomOptionsPairs)
{
CustomOptions.Add(Pair.DisplayName);
Row.CustomOptionDisplayToValue.Add(Pair.DisplayName, Pair.Value);
}
}
else
{
CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key);
}
}
if (CustomOptions.Num() > 0)
{
@@ -2280,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()
@@ -2307,17 +2521,67 @@ void UPS_Editor_MainWidget_Legacy::RefreshDynamicPropertyValues()
FString CurrentVal;
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != CurrentVal)
// Cache hit: the exported value hasn't changed since the last refresh, so the
// DisplayName we'd resolve is identical too. Skip the BP call + match loop
// entirely. This is what keeps GetCurrentDisplayForProperty off the hot tick
// path for props that don't move.
FString DisplayToShow;
if (!Row.LastExportedValue.IsEmpty() && CurrentVal == Row.LastExportedValue)
{
DisplayToShow = Row.LastDisplayToShow;
}
else
{
// Resolve the DisplayName to show in the combo. Priority order:
// 1. Ask the actor directly via GetCurrentDisplayForProperty — the user knows
// their storage format best, so this is authoritative when overridden.
// 2. Otherwise, fall back to matching CurrentVal / normalized-path against
// the options returned by GetCustomOptionsWithValuesForProperty.
DisplayToShow = CurrentVal;
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
const FString FromActor = IPS_Editor_EditableInterface::Execute_GetCurrentDisplayForProperty(Actor, Row.Key);
if (!FromActor.IsEmpty())
{
DisplayToShow = FromActor;
}
}
if (DisplayToShow == CurrentVal && Row.CustomOptionDisplayToValue.Num() > 0)
{
// Normalize class-ref format `TypeName'/Path.X_C'` → `/Path.X_C`.
FString NormalizedCurrent = CurrentVal;
int32 FirstQuote = INDEX_NONE;
int32 LastQuote = INDEX_NONE;
if (CurrentVal.FindChar(TEXT('\''), FirstQuote) && CurrentVal.FindLastChar(TEXT('\''), LastQuote)
&& LastQuote > FirstQuote + 1)
{
NormalizedCurrent = CurrentVal.Mid(FirstQuote + 1, LastQuote - FirstQuote - 1);
}
for (const TPair<FString, FString>& P : Row.CustomOptionDisplayToValue)
{
if (!P.Value.IsEmpty() && (P.Value == CurrentVal || P.Value == NormalizedCurrent))
{
DisplayToShow = P.Key;
break;
}
}
}
// Remember what we resolved so the next frame can short-circuit.
Row.LastExportedValue = CurrentVal;
Row.LastDisplayToShow = DisplayToShow;
}
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != DisplayToShow)
{
// Find matching option
if (Row.EnumOptions.IsValid())
{
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
{
if (*Opt == CurrentVal)
if (*Opt == DisplayToShow)
{
Row.EnumCombo->SetSelectedItem(Opt);
Row.EnumComboText->SetText(FText::FromString(CurrentVal));
Row.EnumComboText->SetText(FText::FromString(DisplayToShow));
break;
}
}
@@ -2463,8 +2727,25 @@ void UPS_Editor_MainWidget_Legacy::ApplyPropertyFromUI(int32 RowIndex)
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
{
// Custom options combo: use selected text directly as property value
NewValueText = Row.EnumComboText->GetText().ToString();
// Custom options combo. If we have a display->value map (structured variant),
// translate the selected display name into its stored value; otherwise fall back
// to the flat-string behavior where display and value are the same.
const FString Display = Row.EnumComboText->GetText().ToString();
if (Row.CustomOptionDisplayToValue.Num() > 0)
{
if (const FString* Mapped = Row.CustomOptionDisplayToValue.Find(Display))
{
NewValueText = *Mapped;
}
else
{
NewValueText = Display;
}
}
else
{
NewValueText = Display;
}
}
else if (Row.CachedEnum && Row.EnumComboText.IsValid())
{
@@ -2546,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());
}
}
@@ -2573,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());
@@ -2853,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())
@@ -3149,7 +3523,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildTimelinePanel()
const FSlateFontInfo Bold = FCoreStyle::GetDefaultFontStyle("Bold", 10);
return SAssignNew(TimelinePanel, SBorder)
.Visibility(EVisibility::Collapsed)
.Visibility(EVisibility::Visible)
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.95f))
.Padding(FMargin(12.0f, 8.0f))
@@ -3479,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

@@ -29,6 +29,14 @@ struct FPS_Editor_PropertyRowWidgetsLegacy
TSharedPtr<STextBlock> EnumComboText;
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
UEnum* CachedEnum = nullptr;
/** DisplayName -> stored Value mapping from GetCustomOptionsWithValuesForProperty. */
TMap<FString, FString> CustomOptionDisplayToValue;
/** Cache to avoid calling GetCurrentDisplayForProperty + the match loop every frame
* when the property hasn't changed. */
FString LastExportedValue;
FString LastDisplayToShow;
};
/**
@@ -72,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;
@@ -99,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;
@@ -120,9 +148,6 @@ private:
TSharedPtr<SWidget> SplineDeletePointButton;
TSharedPtr<SWidget> ChildEditButton;
// Simulate button
TSharedPtr<STextBlock> SimulateButtonText;
// Timeline panel
TSharedPtr<SWidget> TimelinePanel;
TSharedPtr<class SSlider> TimelineSlider;
@@ -130,7 +155,7 @@ private:
TSharedPtr<STextBlock> TimelinePlayButtonText;
TSharedPtr<SEditableTextBox> TimelineDurationBox;
TSharedPtr<SVerticalBox> TimelineTracksContainer;
bool bTimelineVisible = false;
bool bTimelineVisible = true;
FDelegateHandle TimelineChangedHandle;
/** Currently selected timeline key (updated on click). -1 means no selection. */
int32 SelectedTimelineTrackIdx = -1;

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)
{
@@ -99,7 +101,8 @@ void UPS_Editor_MainWidget_UMG::StopSimulation()
{
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
PC->StopSimulation();
// Route through RequestStopSimulation so OnEditorBeforeSimulateStop fires + BT grace.
PC->RequestStopSimulation();
OnSimulateChanged.Broadcast();
}
}
@@ -129,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;
}
@@ -160,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);