diff --git a/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset b/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset index b34e369..8f86ef6 100644 Binary files a/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset and b/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset differ diff --git a/Content/UI/WBP_InteractionBar.uasset b/Content/UI/WBP_InteractionBar.uasset new file mode 100644 index 0000000..15b34f1 Binary files /dev/null and b/Content/UI/WBP_InteractionBar.uasset differ diff --git a/Content/UI/WBP_InteractionContextMenu.uasset b/Content/UI/WBP_InteractionContextMenu.uasset index 7cd0fee..4850b41 100644 Binary files a/Content/UI/WBP_InteractionContextMenu.uasset and b/Content/UI/WBP_InteractionContextMenu.uasset differ diff --git a/Content/UI/WBP_InteractionOptionEntry.uasset b/Content/UI/WBP_InteractionOptionEntry.uasset index 0680b24..2d6444d 100644 Binary files a/Content/UI/WBP_InteractionOptionEntry.uasset and b/Content/UI/WBP_InteractionOptionEntry.uasset differ diff --git a/Content/UI/WBP_InteractionQuick.uasset b/Content/UI/WBP_InteractionQuick.uasset new file mode 100644 index 0000000..6a66df8 Binary files /dev/null and b/Content/UI/WBP_InteractionQuick.uasset differ diff --git a/Source/KingshearthLegacy/Interaction/InteractionComponent.cpp b/Source/KingshearthLegacy/Interaction/InteractionComponent.cpp index db42270..674e1df 100644 --- a/Source/KingshearthLegacy/Interaction/InteractionComponent.cpp +++ b/Source/KingshearthLegacy/Interaction/InteractionComponent.cpp @@ -26,6 +26,7 @@ void UInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType, { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); UpdateFocus(); + UpdateHoldProgress(); } bool UInteractionComponent::PerformInteractionTrace(FHitResult& OutHit) const @@ -120,6 +121,22 @@ void UInteractionComponent::UpdateFocus() OnFocusedInteractableChanged.Broadcast(NewFocusedActor, FocusedOptions); } +void UInteractionComponent::UpdateHoldProgress() +{ + const bool bTimerActive = GetWorld()->GetTimerManager().IsTimerActive(HoldTimerHandle); + const float Progress = bTimerActive && HoldThreshold > 0.0f + ? FMath::Clamp(GetWorld()->GetTimerManager().GetTimerElapsed(HoldTimerHandle) / HoldThreshold, 0.0f, 1.0f) + : 0.0f; + + if (FMath::IsNearlyEqual(Progress, LastBroadcastHoldProgress, KINDA_SMALL_NUMBER)) + { + return; + } + + LastBroadcastHoldProgress = Progress; + OnInteractHoldProgressChanged.Broadcast(Progress); +} + void UInteractionComponent::TryBeginInteract() { UpdateFocus(); diff --git a/Source/KingshearthLegacy/Interaction/InteractionComponent.h b/Source/KingshearthLegacy/Interaction/InteractionComponent.h index da7f11d..dd10ce2 100644 --- a/Source/KingshearthLegacy/Interaction/InteractionComponent.h +++ b/Source/KingshearthLegacy/Interaction/InteractionComponent.h @@ -11,6 +11,7 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnFocusedInteractableChanged, AAct DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnContextMenuOpened, const TArray&, Options, int32, InitialHighlightedIndex); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnContextMenuHighlightChanged, int32, NewHighlightedIndex); DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnContextMenuClosed); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnInteractHoldProgressChanged, float, Progress); /** * Drives the whole "hold E" contextual interaction system for its owning Actor: @@ -53,6 +54,17 @@ public: UPROPERTY(BlueprintAssignable, Category = "Interaction") FOnContextMenuClosed OnContextMenuClosed; + /** + * Fired every tick while the hold timer is running, with the 0-1 fraction of + * HoldThreshold elapsed so far - drives a fill bar for the "hold E" prompt. + * Fires once more with 0 the instant the hold ends, however it ends (release, + * secondary fired, Context Menu opened), so a bound bar always snaps back + * empty. Never fires at all for a 1-option target, since no hold timer is + * started for it. + */ + UPROPERTY(BlueprintAssignable, Category = "Interaction") + FOnInteractHoldProgressChanged OnInteractHoldProgressChanged; + /** Call from the Interact input's Started/pressed event. */ UFUNCTION(BlueprintCallable, Category = "Interaction") void TryBeginInteract(); @@ -106,6 +118,9 @@ protected: /** Re-runs PerformInteractionTrace, updates FocusedActor/FocusedOptions, and broadcasts OnFocusedInteractableChanged if either changed. */ void UpdateFocus(); + /** Re-derives hold progress from HoldTimerHandle every tick and broadcasts OnInteractHoldProgressChanged when it changes. */ + void UpdateHoldProgress(); + /** Bound to the hold timer; only ever runs when CurrentOptions.Num() >= 2. */ void HandleHoldThresholdReached(); @@ -141,4 +156,7 @@ protected: bool bHoldConsumed = false; FTimerHandle HoldTimerHandle; + + /** Last value passed to OnInteractHoldProgressChanged, so UpdateHoldProgress only broadcasts on actual change. */ + float LastBroadcastHoldProgress = 0.0f; }; diff --git a/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.cpp b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.cpp index f22ec64..9d945e6 100644 --- a/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.cpp +++ b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.cpp @@ -2,37 +2,15 @@ #include "InteractionContextMenuWidget.h" #include "InteractionOptionListItem.h" +#include "InteractionPromptWidget.h" #include "Interaction/InteractionComponent.h" -#include "Interaction/Interactable.h" #include "Components/ListView.h" -#include "Components/TextBlock.h" -#include "Components/Widget.h" #include "KingshearthLegacy.h" -namespace -{ - /** Toggles TextContainer when bound (so a background living alongside PromptText hides with it), else PromptText itself. */ - void SetPromptVisible(UWidget* TextContainer, UTextBlock* PromptText, bool bVisible) - { - const ESlateVisibility Visibility = bVisible ? ESlateVisibility::HitTestInvisible : ESlateVisibility::Collapsed; - - if (TextContainer) - { - TextContainer->SetVisibility(Visibility); - } - else if (PromptText) - { - PromptText->SetVisibility(Visibility); - } - } -} - void UInteractionContextMenuWidget::NativeConstruct() { Super::NativeConstruct(); - SetPromptVisible(TextContainer, PromptText, false); - if (OptionsList) { OptionsList->SetVisibility(ESlateVisibility::Collapsed); @@ -43,7 +21,6 @@ void UInteractionContextMenuWidget::InitializeInteraction(UInteractionComponent* { if (InteractionComponent) { - InteractionComponent->OnFocusedInteractableChanged.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleFocusedInteractableChanged); InteractionComponent->OnContextMenuOpened.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuOpened); InteractionComponent->OnContextMenuHighlightChanged.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuHighlightChanged); InteractionComponent->OnContextMenuClosed.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuClosed); @@ -53,72 +30,19 @@ void UInteractionContextMenuWidget::InitializeInteraction(UInteractionComponent* if (InteractionComponent) { - InteractionComponent->OnFocusedInteractableChanged.AddDynamic(this, &UInteractionContextMenuWidget::HandleFocusedInteractableChanged); InteractionComponent->OnContextMenuOpened.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuOpened); InteractionComponent->OnContextMenuHighlightChanged.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuHighlightChanged); InteractionComponent->OnContextMenuClosed.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuClosed); } -} -void UInteractionContextMenuWidget::HandleFocusedInteractableChanged(AActor* FocusedActor, const TArray& Options) -{ - LastFocusedActor = FocusedActor; - LastFocusedOptions = Options; - UpdatePromptVisibility(); -} + UE_LOG(LogKingshearthLegacy, Log, TEXT("[Interaction] ContextMenu widget InitializeInteraction: OptionsList bound: %s, WBP_InteractionQuick bound: %s"), + OptionsList ? TEXT("yes") : TEXT("NO"), + WBP_InteractionQuick ? TEXT("yes") : TEXT("NO - check its Name in the Designer tree matches the native property, and that it's reparented to UInteractionPromptWidget")); -void UInteractionContextMenuWidget::UpdatePromptVisibility() -{ - if (!PromptText) + if (WBP_InteractionQuick) { - return; + WBP_InteractionQuick->InitializeInteraction(InInteractionComponent); } - - // The Context Menu owns the screen while it's open - keep the ever-present prompt - // out of its way until OnContextMenuClosed brings us back here. - if (InteractionComponent && InteractionComponent->IsContextMenuOpen()) - { - SetPromptVisible(TextContainer, PromptText, false); - return; - } - - AActor* FocusedActor = LastFocusedActor.Get(); - if (!FocusedActor || LastFocusedOptions.Num() == 0) - { - SetPromptVisible(TextContainer, PromptText, false); - return; - } - - // "{Verb} with {ObjectName}" on line one (or PromptOverride verbatim, if the primary - // option set one), "Press E" always, then a hint for the hold behaviour only when - // there is one (2 options = secondary, 3+ = Context Menu). - const FInteractionOption& PrimaryOption = LastFocusedOptions[0]; - FString Prompt; - if (!PrimaryOption.PromptOverride.IsEmpty()) - { - Prompt = PrimaryOption.PromptOverride.ToString(); - } - else - { - const FText ObjectName = IInteractable::Execute_GetInteractableDisplayName(FocusedActor); - Prompt = ObjectName.IsEmpty() - ? PrimaryOption.DisplayName.ToString() - : FString::Printf(TEXT("%s with %s"), *PrimaryOption.DisplayName.ToString(), *ObjectName.ToString()); - } - - Prompt += TEXT("\nPress E"); - - if (LastFocusedOptions.Num() == 2) - { - Prompt += TEXT("\nHold E for secondary interaction"); - } - else if (LastFocusedOptions.Num() >= 3) - { - Prompt += TEXT("\nHold E for Context Menu"); - } - - PromptText->SetText(FText::FromString(Prompt)); - SetPromptVisible(TextContainer, PromptText, true); } void UInteractionContextMenuWidget::PopulateOptionsList(const TArray& Options) @@ -175,8 +99,6 @@ void UInteractionContextMenuWidget::HandleContextMenuOpened(const TArraySetVisibility(ESlateVisibility::Visible); } - - UpdatePromptVisibility(); } void UInteractionContextMenuWidget::HandleContextMenuHighlightChanged(int32 NewHighlightedIndex) @@ -194,6 +116,4 @@ void UInteractionContextMenuWidget::HandleContextMenuClosed() OptionsList->ClearListItems(); OptionsList->SetVisibility(ESlateVisibility::Collapsed); } - - UpdatePromptVisibility(); } diff --git a/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.h b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.h index 3dc3abe..7fa15c6 100644 --- a/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.h +++ b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.h @@ -9,20 +9,18 @@ class UInteractionComponent; class UInteractionOptionListItem; +class UInteractionPromptWidget; class UListView; -class UTextBlock; -class UWidget; /** - * One widget covering both interaction UI needs so you only wire up a single thing - * in WBP_PlayerHUD: an ever-present "you're looking at something" prompt (PromptText, - * driven by OnFocusedInteractableChanged - shows any time the look trace is on an - * IInteractable, regardless of option count or whether Interact is even pressed) and - * the Context Menu shown while holding Interact on a 3+-option IInteractable - * (OptionsList, driven by OnContextMenuOpened/HighlightChanged/Closed). Both bound - * widgets are optional and toggle their own visibility independently - add whichever - * you want in the Designer, named exactly as below; all population/highlight/show-hide - * logic lives here in C++. + * The Context Menu shown while holding Interact on a 3+-option IInteractable + * (OptionsList, driven by OnContextMenuOpened/HighlightChanged/Closed). The + * ever-present "you're looking at something" prompt lives in a separate + * nested UInteractionPromptWidget instance (e.g. WBP_InteractionQuick) - + * BindWidget can only see direct nodes of this widget's own tree, not reach + * inside another UserWidget's tree, so that prompt/slider logic can't live + * here; InitializeInteraction forwards to it instead, so the HUD only needs + * to call this one entry point. */ UCLASS(abstract) class UInteractionContextMenuWidget : public UUserWidget @@ -31,7 +29,7 @@ class UInteractionContextMenuWidget : public UUserWidget public: - /** Call once, right after creation (see UPlayerHUDWidget::InitializeHUD), to bind to InInteractionComponent's delegates. */ + /** Call once, right after creation (see UPlayerHUDWidget::InitializeHUD), to bind to InInteractionComponent's delegates, including forwarding to the nested WBP_InteractionQuick prompt widget. */ UFUNCTION(BlueprintCallable, Category = "Interaction") void InitializeInteraction(UInteractionComponent* InInteractionComponent); @@ -45,9 +43,6 @@ protected: /** Selects ListItems[Index] on OptionsList, which drives each row's IUserObjectListEntry::NativeOnItemSelectionChanged. */ void SelectHighlighted(int32 Index); - UFUNCTION() - void HandleFocusedInteractableChanged(AActor* FocusedActor, const TArray& Options); - UFUNCTION() void HandleContextMenuOpened(const TArray& Options, int32 InitialHighlightedIndex); @@ -57,19 +52,6 @@ protected: UFUNCTION() void HandleContextMenuClosed(); - /** Ever-present prompt - shows the primary option's DisplayName any time the look trace is on an IInteractable. Optional, place it in the WBP subclass's Designer tree. */ - UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) - TObjectPtr PromptText; - - /** - * Optional wrapper around PromptText (e.g. a panel/border providing its background) named - * exactly "TextContainer" in the Designer. When bound, its visibility is toggled instead of - * PromptText's own - so a background that lives alongside PromptText, not inside it, actually - * hides too. Falls back to toggling PromptText directly when this isn't bound. - */ - UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) - TObjectPtr TextContainer; - /** * The Context Menu's option list. Optional, place it in the WBP subclass's Designer * tree as a ListView, single-selection, with its Entry Widget Class set to a WBP @@ -79,6 +61,15 @@ protected: UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) TObjectPtr OptionsList; + /** + * The ever-present prompt + hold-progress bar, nested as a WBP_InteractionQuick + * instance (a UInteractionPromptWidget subclass) named exactly "WBP_InteractionQuick" + * in the Designer tree. Optional - InitializeInteraction just skips forwarding to it + * when unbound. + */ + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr WBP_InteractionQuick; + UPROPERTY() TArray CachedOptions; @@ -88,14 +79,4 @@ protected: UPROPERTY() TObjectPtr InteractionComponent; - -private: - - /** Rebuilds PromptText from LastFocused*, collapsing it while the Context Menu is open. Called on focus change and on menu close. */ - void UpdatePromptVisibility(); - - TWeakObjectPtr LastFocusedActor; - - UPROPERTY() - TArray LastFocusedOptions; }; diff --git a/Source/KingshearthLegacy/UI/InteractionPromptWidget.cpp b/Source/KingshearthLegacy/UI/InteractionPromptWidget.cpp new file mode 100644 index 0000000..bce4851 --- /dev/null +++ b/Source/KingshearthLegacy/UI/InteractionPromptWidget.cpp @@ -0,0 +1,178 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "InteractionPromptWidget.h" +#include "Interaction/InteractionComponent.h" +#include "Interaction/Interactable.h" +#include "Components/TextBlock.h" +#include "Components/Widget.h" +#include "SegmentedBarWidget.h" +#include "KingshearthLegacy.h" + +namespace +{ + /** Toggles TextContainer when bound (so a background living alongside PromptText hides with it), else PromptText itself. */ + void SetPromptVisible(UWidget* TextContainer, UTextBlock* PromptText, bool bVisible) + { + const ESlateVisibility Visibility = bVisible ? ESlateVisibility::HitTestInvisible : ESlateVisibility::Collapsed; + + if (TextContainer) + { + TextContainer->SetVisibility(Visibility); + } + else if (PromptText) + { + PromptText->SetVisibility(Visibility); + } + } +} + +void UInteractionPromptWidget::NativeConstruct() +{ + Super::NativeConstruct(); + SetPromptVisible(TextContainer, PromptText, false); + if (InfoText) + { + InfoText->SetVisibility(ESlateVisibility::Collapsed); + } + if (WBP_InteractionsSlider) + { + WBP_InteractionsSlider->SetVisibility(ESlateVisibility::Collapsed); + // USegmentedBarWidget defaults to full (it's shared with the health/stamina bars), but + // the hold bar should start empty and only fill as OnInteractHoldProgressChanged reports. + WBP_InteractionsSlider->SetSegments(0.0f, 0.0f); + } +} + +void UInteractionPromptWidget::InitializeInteraction(UInteractionComponent* InInteractionComponent) +{ + UE_LOG(LogKingshearthLegacy, Log, TEXT("[Interaction] Prompt widget InitializeInteraction: component=%s, PromptText bound: %s, InfoText bound: %s, TextContainer bound: %s, Slider bound: %s"), + *GetNameSafe(InInteractionComponent), + PromptText ? TEXT("yes") : TEXT("NO"), + InfoText ? TEXT("yes") : TEXT("NO"), + TextContainer ? TEXT("yes") : TEXT("NO"), + WBP_InteractionsSlider ? TEXT("yes") : TEXT("NO")); + + if (InteractionComponent) + { + InteractionComponent->OnFocusedInteractableChanged.RemoveDynamic(this, &UInteractionPromptWidget::HandleFocusedInteractableChanged); + InteractionComponent->OnContextMenuOpened.RemoveDynamic(this, &UInteractionPromptWidget::HandleContextMenuOpened); + InteractionComponent->OnContextMenuClosed.RemoveDynamic(this, &UInteractionPromptWidget::HandleContextMenuClosed); + InteractionComponent->OnInteractHoldProgressChanged.RemoveDynamic(this, &UInteractionPromptWidget::HandleHoldProgressChanged); + } + + InteractionComponent = InInteractionComponent; + + if (InteractionComponent) + { + InteractionComponent->OnFocusedInteractableChanged.AddDynamic(this, &UInteractionPromptWidget::HandleFocusedInteractableChanged); + InteractionComponent->OnContextMenuOpened.AddDynamic(this, &UInteractionPromptWidget::HandleContextMenuOpened); + InteractionComponent->OnContextMenuClosed.AddDynamic(this, &UInteractionPromptWidget::HandleContextMenuClosed); + InteractionComponent->OnInteractHoldProgressChanged.AddDynamic(this, &UInteractionPromptWidget::HandleHoldProgressChanged); + } +} + +void UInteractionPromptWidget::HandleHoldProgressChanged(float Progress) +{ + if (WBP_InteractionsSlider) + { + WBP_InteractionsSlider->SetSegments(Progress, 0.0f); + } +} + +void UInteractionPromptWidget::HandleFocusedInteractableChanged(AActor* FocusedActor, const TArray& Options) +{ + UE_LOG(LogKingshearthLegacy, Log, TEXT("[Interaction] Prompt widget HandleFocusedInteractableChanged: actor='%s' options=%d"), + *GetNameSafe(FocusedActor), Options.Num()); + + LastFocusedActor = FocusedActor; + LastFocusedOptions = Options; + UpdatePromptVisibility(); +} + +void UInteractionPromptWidget::HandleContextMenuOpened(const TArray& Options, int32 InitialHighlightedIndex) +{ + UpdatePromptVisibility(); +} + +void UInteractionPromptWidget::HandleContextMenuClosed() +{ + UpdatePromptVisibility(); +} + +void UInteractionPromptWidget::UpdatePromptVisibility() +{ + if (!PromptText) + { + return; + } + + const auto HideAll = [this]() + { + SetPromptVisible(TextContainer, PromptText, false); + if (InfoText) + { + InfoText->SetVisibility(ESlateVisibility::Collapsed); + } + if (WBP_InteractionsSlider) + { + WBP_InteractionsSlider->SetVisibility(ESlateVisibility::Collapsed); + } + }; + + // The Context Menu owns the screen while it's open - keep the ever-present prompt + // out of its way until OnContextMenuClosed brings us back here. + if (InteractionComponent && InteractionComponent->IsContextMenuOpen()) + { + HideAll(); + return; + } + + AActor* FocusedActor = LastFocusedActor.Get(); + if (!FocusedActor || LastFocusedOptions.Num() == 0) + { + HideAll(); + return; + } + + // Primary line: "{Verb} with {ObjectName}" (or PromptOverride verbatim, if the primary + // option set one). Hint line: "Press E" always, plus a hint for the hold behaviour only + // when there is one (2 options = secondary, 3+ = Context Menu). + const FInteractionOption& PrimaryOption = LastFocusedOptions[0]; + FString Prompt; + if (!PrimaryOption.PromptOverride.IsEmpty()) + { + Prompt = PrimaryOption.PromptOverride.ToString(); + } + else + { + const FText ObjectName = IInteractable::Execute_GetInteractableDisplayName(FocusedActor); + Prompt = ObjectName.IsEmpty() + ? PrimaryOption.DisplayName.ToString() + : FString::Printf(TEXT("%s with %s"), *PrimaryOption.DisplayName.ToString(), *ObjectName.ToString()); + } + + FString Hint = TEXT("Press E"); + if (LastFocusedOptions.Num() == 2) + { + Hint += TEXT("\nHold E for secondary interaction"); + } + else if (LastFocusedOptions.Num() >= 3) + { + Hint += TEXT("\nHold E for Context Menu"); + } + + PromptText->SetText(FText::FromString(Prompt)); + SetPromptVisible(TextContainer, PromptText, true); + + if (InfoText) + { + InfoText->SetText(FText::FromString(Hint)); + InfoText->SetVisibility(ESlateVisibility::HitTestInvisible); + } + + if (WBP_InteractionsSlider) + { + WBP_InteractionsSlider->SetVisibility(ESlateVisibility::HitTestInvisible); + WBP_InteractionsSlider->SetSegments(0.0f, 0.0f); + } +} diff --git a/Source/KingshearthLegacy/UI/InteractionPromptWidget.h b/Source/KingshearthLegacy/UI/InteractionPromptWidget.h new file mode 100644 index 0000000..8ac3aad --- /dev/null +++ b/Source/KingshearthLegacy/UI/InteractionPromptWidget.h @@ -0,0 +1,96 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "Interaction/InteractionTypes.h" +#include "InteractionPromptWidget.generated.h" + +class UInteractionComponent; +class USegmentedBarWidget; +class UTextBlock; +class UWidget; + +/** + * The ever-present "you're looking at something" prompt (PromptText, driven by + * OnFocusedInteractableChanged - shows any time the look trace is on an + * IInteractable, regardless of option count or whether Interact is even + * pressed) plus an optional hold-to-interact fill bar. Kept as its own + * UserWidget (e.g. WBP_InteractionQuick) rather than folded into + * UInteractionContextMenuWidget so it can be laid out and nested independently + * of the Context Menu's option list - see UInteractionContextMenuWidget's + * WBP_InteractionQuick binding, which forwards InitializeInteraction here. + */ +UCLASS(abstract) +class UInteractionPromptWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + + /** Call once, right after creation, to bind to InInteractionComponent's delegates. Safe to call again to rebind to a different component. */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void InitializeInteraction(UInteractionComponent* InInteractionComponent); + +protected: + + virtual void NativeConstruct() override; + + UFUNCTION() + void HandleFocusedInteractableChanged(AActor* FocusedActor, const TArray& Options); + + UFUNCTION() + void HandleContextMenuOpened(const TArray& Options, int32 InitialHighlightedIndex); + + UFUNCTION() + void HandleContextMenuClosed(); + + UFUNCTION() + void HandleHoldProgressChanged(float Progress); + + /** Ever-present prompt's primary line - "{Verb} with {ObjectName}" (or PromptOverride verbatim). Named exactly "PromptText" in the Designer. */ + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr PromptText; + + /** + * Optional wrapper around PromptText (e.g. a panel/border providing its background, or the + * Overlay it shares with WBP_InteractionsSlider) named exactly "TextContainer" in the + * Designer. When bound, its visibility is toggled instead of PromptText's own - so a + * background/slider living alongside PromptText, not inside it, actually hides too. Falls + * back to toggling PromptText directly when this isn't bound. + */ + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr TextContainer; + + /** + * Optional second line - "Press E" plus a hint for the hold behaviour (2 options = + * secondary, 3+ = Context Menu). Named exactly "InfoText" in the Designer. Kept separate + * from PromptText so it can sit outside TextContainer (e.g. below the slider bar rather + * than layered on top of it). Toggles visibility on its own, in lockstep with PromptText. + */ + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr InfoText; + + /** + * Optional hold-to-interact fill bar - drop a WBP_SegmentedBar instance into the + * Designer tree named exactly "WBP_InteractionsSlider" and it fills 0 to 1 while + * holding E on a 2+-option target, driven by InteractionComponent's + * OnInteractHoldProgressChanged. Left unbound, holding just does nothing visually. + */ + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr WBP_InteractionsSlider; + + UPROPERTY() + TObjectPtr InteractionComponent; + +private: + + /** Rebuilds PromptText from LastFocused*, collapsing it while the Context Menu is open. */ + void UpdatePromptVisibility(); + + TWeakObjectPtr LastFocusedActor; + + UPROPERTY() + TArray LastFocusedOptions; +};