diff --git a/CLAUDE.md b/CLAUDE.md index 8f3a3a0..e80b612 100644 --- a/CLAUDE.md +++ b/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) - **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 - All plugin files and classes use the `PS_Editor_` prefix (e.g., `PS_Editor_GameMode`, `PS_Editor_Pawn`) - Code and comments in English diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableInterface.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableInterface.h index 22db47b..529f4d4 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableInterface.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_EditableInterface.h @@ -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 = "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")) class PS_EDITOR_API UPS_Editor_EditableInterface : public UInterface { @@ -69,12 +93,49 @@ public: 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") TArray 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 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; }; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp index 00fc42b..b049239 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp @@ -2400,11 +2400,26 @@ void UPS_Editor_MainWidget::RebuildDynamicProperties() // Value widget depends on property type TSharedRef 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 CustomOptions; + Row.CustomOptionDisplayToValue.Empty(); if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass())) { - CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key); + TArray 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) @@ -2648,17 +2663,52 @@ 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) + // 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. + FString 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& P : Row.CustomOptionDisplayToValue) + { + if (!P.Value.IsEmpty() && (P.Value == CurrentVal || P.Value == NormalizedCurrent)) + { + DisplayToShow = P.Key; + break; + } + } + } + + if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != DisplayToShow) { // Find matching option if (Row.EnumOptions.IsValid()) { for (const TSharedPtr& 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 +2854,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()) { diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h index 048c4ba..98c4d03 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h @@ -31,6 +31,13 @@ struct FPS_Editor_PropertyRowWidgets TSharedPtr EnumComboText; TSharedPtr>> 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 CustomOptionDisplayToValue; }; /** diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp index ac92e41..3242996 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp @@ -2063,11 +2063,26 @@ void UPS_Editor_MainWidget_Legacy::RebuildDynamicProperties() // Value widget depends on property type TSharedRef 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 CustomOptions; + Row.CustomOptionDisplayToValue.Empty(); if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass())) { - CustomOptions = IPS_Editor_EditableInterface::Execute_GetCustomOptionsForProperty(Actor, Row.Key); + TArray 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) @@ -2307,17 +2322,52 @@ 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) + // 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. + FString 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& P : Row.CustomOptionDisplayToValue) + { + if (!P.Value.IsEmpty() && (P.Value == CurrentVal || P.Value == NormalizedCurrent)) + { + DisplayToShow = P.Key; + break; + } + } + } + + if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != DisplayToShow) { // Find matching option if (Row.EnumOptions.IsValid()) { for (const TSharedPtr& 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 +2513,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()) { diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.h index 62881c6..d89b8dd 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.h @@ -29,6 +29,9 @@ struct FPS_Editor_PropertyRowWidgetsLegacy TSharedPtr EnumComboText; TSharedPtr>> EnumOptions; // Heap-allocated so OptionsSource pointer survives Row copy UEnum* CachedEnum = nullptr; + + /** DisplayName -> stored Value mapping from GetCustomOptionsWithValuesForProperty. */ + TMap CustomOptionDisplayToValue; }; /**