Compare commits
3 Commits
b069c20af3
...
721c9bb5c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 721c9bb5c3 | |||
| e999a11349 | |||
| bc491b7b94 |
62
CLAUDE.md
62
CLAUDE.md
@@ -10,6 +10,68 @@ Unreal Engine 5.5 project with runtime editor plugin (PS_Editor). The plugin ena
|
|||||||
- **Serialization**: JSON via Json/JsonUtilities modules (FJsonObjectConverter)
|
- **Serialization**: JSON via Json/JsonUtilities modules (FJsonObjectConverter)
|
||||||
- **Input**: Enhanced Input system (AZERTY: arrows + A/Q movement, E/R/T modes)
|
- **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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
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
|
## Naming Conventions
|
||||||
- All plugin files and classes use the `PS_Editor_` prefix (e.g., `PS_Editor_GameMode`, `PS_Editor_Pawn`)
|
- All plugin files and classes use the `PS_Editor_` prefix (e.g., `PS_Editor_GameMode`, `PS_Editor_Pawn`)
|
||||||
- Code and comments in English
|
- Code and comments in English
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "PS_Editor_SplineActor.h"
|
#include "PS_Editor_SplineActor.h"
|
||||||
#include "PS_Editor_SplineEditable.h"
|
#include "PS_Editor_SplineEditable.h"
|
||||||
#include "PS_Editor_PointPlaceable.h"
|
#include "PS_Editor_PointPlaceable.h"
|
||||||
|
#include "PS_Editor_EditableInterface.h"
|
||||||
#include "Engine/LevelStreamingDynamic.h"
|
#include "Engine/LevelStreamingDynamic.h"
|
||||||
#include "GameFramework/Character.h"
|
#include "GameFramework/Character.h"
|
||||||
#include "GameFramework/CharacterMovementComponent.h"
|
#include "GameFramework/CharacterMovementComponent.h"
|
||||||
@@ -237,6 +238,16 @@ void APS_Editor_PlayerController::CloseEditor()
|
|||||||
|
|
||||||
void APS_Editor_PlayerController::StartSimulation()
|
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;
|
if (bSimulating) return;
|
||||||
bSimulating = true;
|
bSimulating = true;
|
||||||
|
|
||||||
@@ -286,7 +297,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);
|
AIC->SetActorTickEnabled(true);
|
||||||
if (UBrainComponent* Brain = AIC->GetBrainComponent())
|
if (UBrainComponent* Brain = AIC->GetBrainComponent())
|
||||||
@@ -298,11 +320,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());
|
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Simulation started (%d actors saved)"), SimulationSavedTransforms.Num());
|
||||||
}
|
}
|
||||||
|
|
||||||
void APS_Editor_PlayerController::SetSimulationPaused(bool bPaused)
|
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
|
// 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
|
// transforms — the timeline's Pause uses this so characters freeze instead of snapping
|
||||||
// back to their pre-play positions.
|
// back to their pre-play positions.
|
||||||
@@ -332,9 +378,73 @@ void APS_Editor_PlayerController::SetSimulationPaused(bool bPaused)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void APS_Editor_PlayerController::RequestStopSimulation()
|
||||||
|
{
|
||||||
|
if (!bSimulating) return;
|
||||||
|
|
||||||
|
// If a request is already scheduled, do nothing (debounce double-clicks on Stop).
|
||||||
|
if (UWorld* World = GetWorld())
|
||||||
|
{
|
||||||
|
if (PendingStopSimulationHandle.IsValid()) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire OnEditorBeforeSimulateStop immediately on every actor so BP handlers can react
|
||||||
|
// while the BT is still live. Then schedule the real cut for ~500ms later.
|
||||||
|
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 (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()
|
void APS_Editor_PlayerController::StopSimulation()
|
||||||
{
|
{
|
||||||
if (!bSimulating) return;
|
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;
|
bSimulating = false;
|
||||||
|
|
||||||
// Disable AI logic and re-apply rotation/movement overrides on all spawned pawns
|
// Disable AI logic and re-apply rotation/movement overrides on all spawned pawns
|
||||||
|
|||||||
@@ -62,14 +62,24 @@ public:
|
|||||||
|
|
||||||
// ---- Simulate Mode ----
|
// ---- Simulate Mode ----
|
||||||
|
|
||||||
/** Start simulation: enable AI, store actor positions, disable saving. */
|
/** Start simulation: enable AI, store actor positions, disable saving.
|
||||||
|
* Cancels any in-flight deferred RequestStopSimulation. */
|
||||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||||
void StartSimulation();
|
void StartSimulation();
|
||||||
|
|
||||||
/** Stop simulation: disable AI, restore actor positions, re-enable saving. */
|
/** Stop simulation: disable AI, restore actor positions, re-enable saving.
|
||||||
|
* Synchronous — fires OnEditorSimulateStop and cuts AI immediately. Callers that want
|
||||||
|
* a BT grace period (with OnEditorBeforeSimulateStop) should use RequestStopSimulation. */
|
||||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||||
void StopSimulation();
|
void StopSimulation();
|
||||||
|
|
||||||
|
/** Request a deferred stop: fires OnEditorBeforeSimulateStop immediately, then schedules
|
||||||
|
* the real StopSimulation after a grace period (500ms by default) so BT has time to
|
||||||
|
* react to whatever the BP handler does in OnEditorBeforeSimulateStop.
|
||||||
|
* Timeline Stop and the Simulate toolbar button both go through this. */
|
||||||
|
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||||
|
void RequestStopSimulation();
|
||||||
|
|
||||||
/** True while simulation is running. */
|
/** True while simulation is running. */
|
||||||
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||||
bool IsSimulating() const { return bSimulating; }
|
bool IsSimulating() const { return bSimulating; }
|
||||||
@@ -165,6 +175,9 @@ private:
|
|||||||
/** Saved transforms for all spawned actors before simulation, to restore on stop. */
|
/** Saved transforms for all spawned actors before simulation, to restore on stop. */
|
||||||
TMap<TWeakObjectPtr<AActor>, FTransform> SimulationSavedTransforms;
|
TMap<TWeakObjectPtr<AActor>, FTransform> SimulationSavedTransforms;
|
||||||
|
|
||||||
|
/** Timer handle for the deferred StopSimulation used by RequestStopSimulation. */
|
||||||
|
FTimerHandle PendingStopSimulationHandle;
|
||||||
|
|
||||||
/** Currently loaded sublevel instance. */
|
/** Currently loaded sublevel instance. */
|
||||||
UPROPERTY()
|
UPROPERTY()
|
||||||
TObjectPtr<ULevelStreamingDynamic> CurrentSublevel;
|
TObjectPtr<ULevelStreamingDynamic> CurrentSublevel;
|
||||||
|
|||||||
@@ -4,6 +4,30 @@
|
|||||||
#include "UObject/Interface.h"
|
#include "UObject/Interface.h"
|
||||||
#include "PS_Editor_EditableInterface.generated.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 = "ASTERION|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 = "ASTERION|PS_Editor")
|
||||||
|
FString Value;
|
||||||
|
};
|
||||||
|
|
||||||
UINTERFACE(BlueprintType, meta = (DisplayName = "PS Editor Editable"))
|
UINTERFACE(BlueprintType, meta = (DisplayName = "PS Editor Editable"))
|
||||||
class PS_EDITOR_API UPS_Editor_EditableInterface : public UInterface
|
class PS_EDITOR_API UPS_Editor_EditableInterface : public UInterface
|
||||||
{
|
{
|
||||||
@@ -60,6 +84,55 @@ public:
|
|||||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_Editor")
|
||||||
void OnEditorModeChanged(bool bIsEditing);
|
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 = "ASTERION|PS_Editor")
|
||||||
|
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 = "ASTERION|PS_Editor")
|
||||||
|
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 = "ASTERION|PS_Editor")
|
||||||
|
void OnEditorBeforeSimulateStop();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called every frame while in editor mode.
|
* Called every frame while in editor mode.
|
||||||
* Use this to update visuals dynamically based on property changes, animate helpers, etc.
|
* Use this to update visuals dynamically based on property changes, animate helpers, etc.
|
||||||
@@ -69,12 +142,49 @@ public:
|
|||||||
void OnEditorTick(float DeltaTime);
|
void OnEditorTick(float DeltaTime);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return custom dropdown options for a given property.
|
* Return custom dropdown options for a given property (display == stored value).
|
||||||
* If the returned array is non-empty, the editor shows a combo box with these options
|
* Simple form: the string shown in the combo is also the value written to the property.
|
||||||
* instead of the default widget for that property type.
|
* Use GetCustomOptionsWithValuesForProperty when display and storage need to differ.
|
||||||
|
*
|
||||||
* @param PropertyPath The property path (e.g. "WeaponName" or "MyComponent.WeaponType").
|
* @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 = "ASTERION|PS_Editor")
|
||||||
TArray<FString> GetCustomOptionsForProperty(const FString& PropertyPath) const;
|
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 = "ASTERION|PS_Editor")
|
||||||
|
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 = "ASTERION|PS_Editor")
|
||||||
|
FString GetCurrentDisplayForProperty(const FString& PropertyPath) const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "PS_Editor_EditableComponent.h"
|
#include "PS_Editor_EditableComponent.h"
|
||||||
#include "PS_Editor_EditableInterface.h"
|
#include "PS_Editor_EditableInterface.h"
|
||||||
#include "PS_Editor_PlayerController.h"
|
#include "PS_Editor_PlayerController.h"
|
||||||
|
#include "PS_Editor_SpawnManager.h"
|
||||||
#include "Engine/World.h"
|
#include "Engine/World.h"
|
||||||
#include "Components/ActorComponent.h"
|
#include "Components/ActorComponent.h"
|
||||||
#include "GameFramework/Actor.h"
|
#include "GameFramework/Actor.h"
|
||||||
@@ -87,11 +88,21 @@ bool UPS_Editor_TimelineSubsystem::IsTickable() const
|
|||||||
|
|
||||||
void UPS_Editor_TimelineSubsystem::Play()
|
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)
|
if (CurrentTime >= Data.Duration)
|
||||||
{
|
{
|
||||||
CurrentTime = 0.f;
|
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;
|
bIsPlaying = true;
|
||||||
|
|
||||||
// Timeline and simulation are inherently linked: start simulation if it isn't running,
|
// Timeline and simulation are inherently linked: start simulation if it isn't running,
|
||||||
@@ -135,20 +146,98 @@ void UPS_Editor_TimelineSubsystem::Stop()
|
|||||||
LastAppliedValues.Empty();
|
LastAppliedValues.Empty();
|
||||||
LastAppliedValues.SetNum(Data.Tracks.Num());
|
LastAppliedValues.SetNum(Data.Tracks.Num());
|
||||||
|
|
||||||
// Full reset: stop simulation (restores pre-play transforms) and apply t=0 values so
|
// Restore pre-play baseline values onto the actors BEFORE asking the PC to stop sim.
|
||||||
// the scene visually matches the timeline start.
|
// 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 (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld()))
|
||||||
{
|
{
|
||||||
if (EditorPC->IsSimulating())
|
if (EditorPC->IsSimulating())
|
||||||
{
|
{
|
||||||
EditorPC->StopSimulation();
|
EditorPC->RequestStopSimulation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplyValuesAtTime(CurrentTime);
|
|
||||||
OnTimelineChanged.Broadcast();
|
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()
|
||||||
|
{
|
||||||
|
if (PrePlayBaseline.Num() == 0) return;
|
||||||
|
|
||||||
|
// Server-only write path (same guard as ApplyValuesAtTime).
|
||||||
|
if (UWorld* World = GetWorld())
|
||||||
|
{
|
||||||
|
if (World->GetNetMode() == NM_Client) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const FPS_Editor_TimelineTrack& Track : Data.Tracks)
|
||||||
|
{
|
||||||
|
const TPair<FGuid, FName> Key(Track.ActorId, Track.PropertyPath);
|
||||||
|
const FString* BaselineValue = PrePlayBaseline.Find(Key);
|
||||||
|
if (!BaselineValue) continue;
|
||||||
|
|
||||||
|
AActor* Actor = FindActorById(Track.ActorId);
|
||||||
|
if (!Actor) continue;
|
||||||
|
UObject* Target = nullptr;
|
||||||
|
FProperty* Prop = nullptr;
|
||||||
|
ResolveTarget(Actor, Track.PropertyPath, Target, Prop);
|
||||||
|
if (!Target || !Prop) continue;
|
||||||
|
|
||||||
|
Prop->ImportText_InContainer(**BaselineValue, 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 the map — a subsequent Stop (or Restart) must still restore the same baseline.
|
||||||
|
// SetData/Clear are the only resetters.
|
||||||
|
}
|
||||||
|
|
||||||
void UPS_Editor_TimelineSubsystem::Restart()
|
void UPS_Editor_TimelineSubsystem::Restart()
|
||||||
{
|
{
|
||||||
// Rewind + replay from the start. Done in two steps so simulation transforms are restored
|
// Rewind + replay from the start. Done in two steps so simulation transforms are restored
|
||||||
@@ -194,6 +283,13 @@ void UPS_Editor_TimelineSubsystem::SetData(const FPS_Editor_TimelineData& NewDat
|
|||||||
bIsPlaying = false;
|
bIsPlaying = false;
|
||||||
LastAppliedValues.Empty();
|
LastAppliedValues.Empty();
|
||||||
LastAppliedValues.SetNum(Data.Tracks.Num());
|
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();
|
OnTimelineChanged.Broadcast();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +299,7 @@ void UPS_Editor_TimelineSubsystem::Clear()
|
|||||||
CurrentTime = 0.f;
|
CurrentTime = 0.f;
|
||||||
bIsPlaying = false;
|
bIsPlaying = false;
|
||||||
LastAppliedValues.Empty();
|
LastAppliedValues.Empty();
|
||||||
|
PrePlayBaseline.Reset();
|
||||||
OnTimelineChanged.Broadcast();
|
OnTimelineChanged.Broadcast();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +345,10 @@ bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, co
|
|||||||
NewTrack.PropertyPath = PropertyPath;
|
NewTrack.PropertyPath = PropertyPath;
|
||||||
TrackIndex = Data.Tracks.Add(NewTrack);
|
TrackIndex = Data.Tracks.Add(NewTrack);
|
||||||
LastAppliedValues.SetNum(Data.Tracks.Num());
|
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];
|
FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex];
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,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. */
|
/** 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;
|
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. */
|
/** Map of stable IDs for every actor that participates in the timeline / scene registry. */
|
||||||
TMap<TWeakObjectPtr<AActor>, FGuid> ActorIds;
|
TMap<TWeakObjectPtr<AActor>, FGuid> ActorIds;
|
||||||
|
|
||||||
@@ -160,4 +167,16 @@ private:
|
|||||||
|
|
||||||
/** Resolve the UObject target (actor or component) and the FProperty pointer. Nullable. */
|
/** Resolve the UObject target (actor or component) and the FProperty pointer. Nullable. */
|
||||||
static void ResolveTarget(AActor* Actor, const FName& PropertyPath, UObject*& OutTarget, FProperty*& OutProperty);
|
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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1079,74 +1079,6 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildSceneButtons()
|
|||||||
+ SHorizontalBox::Slot()
|
+ SHorizontalBox::Slot()
|
||||||
.AutoWidth()
|
.AutoWidth()
|
||||||
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
|
.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)
|
SNew(SButton)
|
||||||
.ButtonStyle(StyleFlatButton.Get())
|
.ButtonStyle(StyleFlatButton.Get())
|
||||||
@@ -1816,30 +1748,7 @@ void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDelt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update simulate button text / color. Keep the label "Simulate" / "Stop" regardless of
|
// (Simulate toolbar button removed — Play/Pause/Restart/Stop live in the timeline panel.)
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RefreshPropertiesFromSelection();
|
RefreshPropertiesFromSelection();
|
||||||
RefreshOutliner();
|
RefreshOutliner();
|
||||||
@@ -2400,12 +2309,27 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties()
|
|||||||
// Value widget depends on property type
|
// Value widget depends on property type
|
||||||
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
|
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;
|
TArray<FString> CustomOptions;
|
||||||
|
Row.CustomOptionDisplayToValue.Empty();
|
||||||
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
|
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);
|
CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (CustomOptions.Num() > 0)
|
if (CustomOptions.Num() > 0)
|
||||||
{
|
{
|
||||||
@@ -2648,17 +2572,64 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
|
|||||||
FString CurrentVal;
|
FString CurrentVal;
|
||||||
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
|
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
|
// Find matching option
|
||||||
if (Row.EnumOptions.IsValid())
|
if (Row.EnumOptions.IsValid())
|
||||||
{
|
{
|
||||||
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
|
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
|
||||||
{
|
{
|
||||||
if (*Opt == CurrentVal)
|
if (*Opt == DisplayToShow)
|
||||||
{
|
{
|
||||||
Row.EnumCombo->SetSelectedItem(Opt);
|
Row.EnumCombo->SetSelectedItem(Opt);
|
||||||
Row.EnumComboText->SetText(FText::FromString(CurrentVal));
|
Row.EnumComboText->SetText(FText::FromString(DisplayToShow));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2804,8 +2775,25 @@ void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
|
|||||||
|
|
||||||
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
|
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
|
||||||
{
|
{
|
||||||
// Custom options combo: use selected text directly as property value
|
// Custom options combo. If we have a display->value map (structured variant),
|
||||||
NewValueText = Row.EnumComboText->GetText().ToString();
|
// 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())
|
else if (Row.CachedEnum && Row.EnumComboText.IsValid())
|
||||||
{
|
{
|
||||||
@@ -3491,7 +3479,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildTimelinePanel()
|
|||||||
const FSlateFontInfo Bold = FPS_Editor_UIStyle::BodyFontBold(S);
|
const FSlateFontInfo Bold = FPS_Editor_UIStyle::BodyFontBold(S);
|
||||||
|
|
||||||
return SAssignNew(TimelinePanel, SBorder)
|
return SAssignNew(TimelinePanel, SBorder)
|
||||||
.Visibility(EVisibility::Collapsed)
|
.Visibility(EVisibility::Visible)
|
||||||
.BorderImage(BrushGlassStrong.Get())
|
.BorderImage(BrushGlassStrong.Get())
|
||||||
.Padding(FMargin(12.0f, 8.0f))
|
.Padding(FMargin(12.0f, 8.0f))
|
||||||
[
|
[
|
||||||
@@ -4187,7 +4175,9 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildTopbar()
|
|||||||
{
|
{
|
||||||
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
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();
|
else PC->StartSimulation();
|
||||||
}
|
}
|
||||||
return FReply::Handled();
|
return FReply::Handled();
|
||||||
@@ -4228,7 +4218,9 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildTopbar()
|
|||||||
{
|
{
|
||||||
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
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();
|
else PC->StartSimulation();
|
||||||
}
|
}
|
||||||
return FReply::Handled();
|
return FReply::Handled();
|
||||||
@@ -4311,18 +4303,6 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildTopbar()
|
|||||||
FPS_Editor_UIStyle::MakeIconFollowForeground(S, EPS_Editor_Icon::Sidebar, 14.0f)
|
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
|
// Inspector (panel right) toggle
|
||||||
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(2.0f, 0.0f)
|
+ SHorizontalBox::Slot().AutoWidth().VAlign(VAlign_Center).Padding(2.0f, 0.0f)
|
||||||
[
|
[
|
||||||
@@ -4874,7 +4854,8 @@ void UPS_Editor_MainWidget::ExecuteCommand(const FString& CommandId)
|
|||||||
{
|
{
|
||||||
if (PC)
|
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(); }
|
else if (CommandId == TEXT("timeline")) { ToggleTimelineVisible(); }
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ struct FPS_Editor_PropertyRowWidgets
|
|||||||
TSharedPtr<STextBlock> EnumComboText;
|
TSharedPtr<STextBlock> EnumComboText;
|
||||||
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
|
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
|
||||||
UEnum* CachedEnum = nullptr;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,9 +155,6 @@ private:
|
|||||||
TSharedPtr<SWidget> SplineDeletePointButton;
|
TSharedPtr<SWidget> SplineDeletePointButton;
|
||||||
TSharedPtr<SWidget> ChildEditButton;
|
TSharedPtr<SWidget> ChildEditButton;
|
||||||
|
|
||||||
// Simulate button
|
|
||||||
TSharedPtr<STextBlock> SimulateButtonText;
|
|
||||||
|
|
||||||
// Timeline panel
|
// Timeline panel
|
||||||
TSharedPtr<SWidget> TimelinePanel;
|
TSharedPtr<SWidget> TimelinePanel;
|
||||||
TSharedPtr<class SSlider> TimelineSlider;
|
TSharedPtr<class SSlider> TimelineSlider;
|
||||||
@@ -152,7 +162,7 @@ private:
|
|||||||
TSharedPtr<STextBlock> TimelinePlayButtonText;
|
TSharedPtr<STextBlock> TimelinePlayButtonText;
|
||||||
TSharedPtr<SEditableTextBox> TimelineDurationBox;
|
TSharedPtr<SEditableTextBox> TimelineDurationBox;
|
||||||
TSharedPtr<SVerticalBox> TimelineTracksContainer;
|
TSharedPtr<SVerticalBox> TimelineTracksContainer;
|
||||||
bool bTimelineVisible = false;
|
bool bTimelineVisible = true;
|
||||||
FDelegateHandle TimelineChangedHandle;
|
FDelegateHandle TimelineChangedHandle;
|
||||||
/** Currently selected timeline key (updated on click). -1 means no selection. */
|
/** Currently selected timeline key (updated on click). -1 means no selection. */
|
||||||
int32 SelectedTimelineTrackIdx = -1;
|
int32 SelectedTimelineTrackIdx = -1;
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::RebuildWidget()
|
|||||||
BuildOutliner()
|
BuildOutliner()
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
// Timeline panel (hidden by default; toggled via toolbar button)
|
// Timeline panel (always visible — toolbar toggle removed)
|
||||||
+ SOverlay::Slot()
|
+ SOverlay::Slot()
|
||||||
.HAlign(HAlign_Fill)
|
.HAlign(HAlign_Fill)
|
||||||
.VAlign(VAlign_Bottom)
|
.VAlign(VAlign_Bottom)
|
||||||
@@ -907,72 +907,6 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildSceneButtons()
|
|||||||
+ SHorizontalBox::Slot()
|
+ SHorizontalBox::Slot()
|
||||||
.AutoWidth()
|
.AutoWidth()
|
||||||
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
|
.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)
|
SNew(SButton)
|
||||||
.OnClicked_Lambda([this]() -> FReply
|
.OnClicked_Lambda([this]() -> FReply
|
||||||
@@ -1566,30 +1500,7 @@ void UPS_Editor_MainWidget_Legacy::NativeTick(const FGeometry& MyGeometry, float
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update simulate button text / color. Keep the label "Simulate" / "Stop" regardless of
|
// (Simulate toolbar button removed — Play/Pause/Restart/Stop live in the timeline panel.)
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RefreshPropertiesFromSelection();
|
RefreshPropertiesFromSelection();
|
||||||
RefreshOutliner();
|
RefreshOutliner();
|
||||||
@@ -2063,12 +1974,27 @@ void UPS_Editor_MainWidget_Legacy::RebuildDynamicProperties()
|
|||||||
// Value widget depends on property type
|
// Value widget depends on property type
|
||||||
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
|
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;
|
TArray<FString> CustomOptions;
|
||||||
|
Row.CustomOptionDisplayToValue.Empty();
|
||||||
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
|
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);
|
CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (CustomOptions.Num() > 0)
|
if (CustomOptions.Num() > 0)
|
||||||
{
|
{
|
||||||
@@ -2307,17 +2233,67 @@ void UPS_Editor_MainWidget_Legacy::RefreshDynamicPropertyValues()
|
|||||||
FString CurrentVal;
|
FString CurrentVal;
|
||||||
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
|
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
|
// Find matching option
|
||||||
if (Row.EnumOptions.IsValid())
|
if (Row.EnumOptions.IsValid())
|
||||||
{
|
{
|
||||||
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
|
for (const TSharedPtr<FString>& Opt : *Row.EnumOptions)
|
||||||
{
|
{
|
||||||
if (*Opt == CurrentVal)
|
if (*Opt == DisplayToShow)
|
||||||
{
|
{
|
||||||
Row.EnumCombo->SetSelectedItem(Opt);
|
Row.EnumCombo->SetSelectedItem(Opt);
|
||||||
Row.EnumComboText->SetText(FText::FromString(CurrentVal));
|
Row.EnumComboText->SetText(FText::FromString(DisplayToShow));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2463,8 +2439,25 @@ void UPS_Editor_MainWidget_Legacy::ApplyPropertyFromUI(int32 RowIndex)
|
|||||||
|
|
||||||
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
|
if (!Row.CachedEnum && Row.EnumComboText.IsValid())
|
||||||
{
|
{
|
||||||
// Custom options combo: use selected text directly as property value
|
// Custom options combo. If we have a display->value map (structured variant),
|
||||||
NewValueText = Row.EnumComboText->GetText().ToString();
|
// 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())
|
else if (Row.CachedEnum && Row.EnumComboText.IsValid())
|
||||||
{
|
{
|
||||||
@@ -3149,7 +3142,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget_Legacy::BuildTimelinePanel()
|
|||||||
const FSlateFontInfo Bold = FCoreStyle::GetDefaultFontStyle("Bold", 10);
|
const FSlateFontInfo Bold = FCoreStyle::GetDefaultFontStyle("Bold", 10);
|
||||||
|
|
||||||
return SAssignNew(TimelinePanel, SBorder)
|
return SAssignNew(TimelinePanel, SBorder)
|
||||||
.Visibility(EVisibility::Collapsed)
|
.Visibility(EVisibility::Visible)
|
||||||
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
|
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
|
||||||
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.95f))
|
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.95f))
|
||||||
.Padding(FMargin(12.0f, 8.0f))
|
.Padding(FMargin(12.0f, 8.0f))
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ struct FPS_Editor_PropertyRowWidgetsLegacy
|
|||||||
TSharedPtr<STextBlock> EnumComboText;
|
TSharedPtr<STextBlock> EnumComboText;
|
||||||
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
|
TSharedPtr<TArray<TSharedPtr<FString>>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy
|
||||||
UEnum* CachedEnum = nullptr;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,9 +128,6 @@ private:
|
|||||||
TSharedPtr<SWidget> SplineDeletePointButton;
|
TSharedPtr<SWidget> SplineDeletePointButton;
|
||||||
TSharedPtr<SWidget> ChildEditButton;
|
TSharedPtr<SWidget> ChildEditButton;
|
||||||
|
|
||||||
// Simulate button
|
|
||||||
TSharedPtr<STextBlock> SimulateButtonText;
|
|
||||||
|
|
||||||
// Timeline panel
|
// Timeline panel
|
||||||
TSharedPtr<SWidget> TimelinePanel;
|
TSharedPtr<SWidget> TimelinePanel;
|
||||||
TSharedPtr<class SSlider> TimelineSlider;
|
TSharedPtr<class SSlider> TimelineSlider;
|
||||||
@@ -130,7 +135,7 @@ private:
|
|||||||
TSharedPtr<STextBlock> TimelinePlayButtonText;
|
TSharedPtr<STextBlock> TimelinePlayButtonText;
|
||||||
TSharedPtr<SEditableTextBox> TimelineDurationBox;
|
TSharedPtr<SEditableTextBox> TimelineDurationBox;
|
||||||
TSharedPtr<SVerticalBox> TimelineTracksContainer;
|
TSharedPtr<SVerticalBox> TimelineTracksContainer;
|
||||||
bool bTimelineVisible = false;
|
bool bTimelineVisible = true;
|
||||||
FDelegateHandle TimelineChangedHandle;
|
FDelegateHandle TimelineChangedHandle;
|
||||||
/** Currently selected timeline key (updated on click). -1 means no selection. */
|
/** Currently selected timeline key (updated on click). -1 means no selection. */
|
||||||
int32 SelectedTimelineTrackIdx = -1;
|
int32 SelectedTimelineTrackIdx = -1;
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ void UPS_Editor_MainWidget_UMG::StopSimulation()
|
|||||||
{
|
{
|
||||||
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
if (APS_Editor_PlayerController* PC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
|
||||||
{
|
{
|
||||||
PC->StopSimulation();
|
// Route through RequestStopSimulation so OnEditorBeforeSimulateStop fires + BT grace.
|
||||||
|
PC->RequestStopSimulation();
|
||||||
OnSimulateChanged.Broadcast();
|
OnSimulateChanged.Broadcast();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user