Contextual interaction system #6

Merged
Dolobarsch merged 7 commits from Contextual-Interaction-System into main 2026-09-14 13:45:06 +02:00
11 changed files with 334 additions and 124 deletions
Showing only changes of commit e4fb702a1c - Show all commits
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -26,6 +26,7 @@ void UInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType,
{ {
Super::TickComponent(DeltaTime, TickType, ThisTickFunction); Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
UpdateFocus(); UpdateFocus();
UpdateHoldProgress();
} }
bool UInteractionComponent::PerformInteractionTrace(FHitResult& OutHit) const bool UInteractionComponent::PerformInteractionTrace(FHitResult& OutHit) const
@@ -120,6 +121,22 @@ void UInteractionComponent::UpdateFocus()
OnFocusedInteractableChanged.Broadcast(NewFocusedActor, FocusedOptions); 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() void UInteractionComponent::TryBeginInteract()
{ {
UpdateFocus(); UpdateFocus();
@@ -11,6 +11,7 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnFocusedInteractableChanged, AAct
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnContextMenuOpened, const TArray<FInteractionOption>&, Options, int32, InitialHighlightedIndex); DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnContextMenuOpened, const TArray<FInteractionOption>&, Options, int32, InitialHighlightedIndex);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnContextMenuHighlightChanged, int32, NewHighlightedIndex); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnContextMenuHighlightChanged, int32, NewHighlightedIndex);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnContextMenuClosed); 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: * Drives the whole "hold E" contextual interaction system for its owning Actor:
@@ -53,6 +54,17 @@ public:
UPROPERTY(BlueprintAssignable, Category = "Interaction") UPROPERTY(BlueprintAssignable, Category = "Interaction")
FOnContextMenuClosed OnContextMenuClosed; 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. */ /** Call from the Interact input's Started/pressed event. */
UFUNCTION(BlueprintCallable, Category = "Interaction") UFUNCTION(BlueprintCallable, Category = "Interaction")
void TryBeginInteract(); void TryBeginInteract();
@@ -106,6 +118,9 @@ protected:
/** Re-runs PerformInteractionTrace, updates FocusedActor/FocusedOptions, and broadcasts OnFocusedInteractableChanged if either changed. */ /** Re-runs PerformInteractionTrace, updates FocusedActor/FocusedOptions, and broadcasts OnFocusedInteractableChanged if either changed. */
void UpdateFocus(); 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. */ /** Bound to the hold timer; only ever runs when CurrentOptions.Num() >= 2. */
void HandleHoldThresholdReached(); void HandleHoldThresholdReached();
@@ -141,4 +156,7 @@ protected:
bool bHoldConsumed = false; bool bHoldConsumed = false;
FTimerHandle HoldTimerHandle; FTimerHandle HoldTimerHandle;
/** Last value passed to OnInteractHoldProgressChanged, so UpdateHoldProgress only broadcasts on actual change. */
float LastBroadcastHoldProgress = 0.0f;
}; };
@@ -2,37 +2,15 @@
#include "InteractionContextMenuWidget.h" #include "InteractionContextMenuWidget.h"
#include "InteractionOptionListItem.h" #include "InteractionOptionListItem.h"
#include "InteractionPromptWidget.h"
#include "Interaction/InteractionComponent.h" #include "Interaction/InteractionComponent.h"
#include "Interaction/Interactable.h"
#include "Components/ListView.h" #include "Components/ListView.h"
#include "Components/TextBlock.h"
#include "Components/Widget.h"
#include "KingshearthLegacy.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() void UInteractionContextMenuWidget::NativeConstruct()
{ {
Super::NativeConstruct(); Super::NativeConstruct();
SetPromptVisible(TextContainer, PromptText, false);
if (OptionsList) if (OptionsList)
{ {
OptionsList->SetVisibility(ESlateVisibility::Collapsed); OptionsList->SetVisibility(ESlateVisibility::Collapsed);
@@ -43,7 +21,6 @@ void UInteractionContextMenuWidget::InitializeInteraction(UInteractionComponent*
{ {
if (InteractionComponent) if (InteractionComponent)
{ {
InteractionComponent->OnFocusedInteractableChanged.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleFocusedInteractableChanged);
InteractionComponent->OnContextMenuOpened.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuOpened); InteractionComponent->OnContextMenuOpened.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuOpened);
InteractionComponent->OnContextMenuHighlightChanged.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuHighlightChanged); InteractionComponent->OnContextMenuHighlightChanged.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuHighlightChanged);
InteractionComponent->OnContextMenuClosed.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuClosed); InteractionComponent->OnContextMenuClosed.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuClosed);
@@ -53,72 +30,19 @@ void UInteractionContextMenuWidget::InitializeInteraction(UInteractionComponent*
if (InteractionComponent) if (InteractionComponent)
{ {
InteractionComponent->OnFocusedInteractableChanged.AddDynamic(this, &UInteractionContextMenuWidget::HandleFocusedInteractableChanged);
InteractionComponent->OnContextMenuOpened.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuOpened); InteractionComponent->OnContextMenuOpened.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuOpened);
InteractionComponent->OnContextMenuHighlightChanged.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuHighlightChanged); InteractionComponent->OnContextMenuHighlightChanged.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuHighlightChanged);
InteractionComponent->OnContextMenuClosed.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuClosed); InteractionComponent->OnContextMenuClosed.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuClosed);
} }
}
void UInteractionContextMenuWidget::HandleFocusedInteractableChanged(AActor* FocusedActor, const TArray<FInteractionOption>& Options) UE_LOG(LogKingshearthLegacy, Log, TEXT("[Interaction] ContextMenu widget InitializeInteraction: OptionsList bound: %s, WBP_InteractionQuick bound: %s"),
{ OptionsList ? TEXT("yes") : TEXT("NO"),
LastFocusedActor = FocusedActor; WBP_InteractionQuick ? TEXT("yes") : TEXT("NO - check its Name in the Designer tree matches the native property, and that it's reparented to UInteractionPromptWidget"));
LastFocusedOptions = Options;
UpdatePromptVisibility();
}
void UInteractionContextMenuWidget::UpdatePromptVisibility() if (WBP_InteractionQuick)
{
if (!PromptText)
{ {
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<FInteractionOption>& Options) void UInteractionContextMenuWidget::PopulateOptionsList(const TArray<FInteractionOption>& Options)
@@ -175,8 +99,6 @@ void UInteractionContextMenuWidget::HandleContextMenuOpened(const TArray<FIntera
{ {
OptionsList->SetVisibility(ESlateVisibility::Visible); OptionsList->SetVisibility(ESlateVisibility::Visible);
} }
UpdatePromptVisibility();
} }
void UInteractionContextMenuWidget::HandleContextMenuHighlightChanged(int32 NewHighlightedIndex) void UInteractionContextMenuWidget::HandleContextMenuHighlightChanged(int32 NewHighlightedIndex)
@@ -194,6 +116,4 @@ void UInteractionContextMenuWidget::HandleContextMenuClosed()
OptionsList->ClearListItems(); OptionsList->ClearListItems();
OptionsList->SetVisibility(ESlateVisibility::Collapsed); OptionsList->SetVisibility(ESlateVisibility::Collapsed);
} }
UpdatePromptVisibility();
} }
@@ -9,20 +9,18 @@
class UInteractionComponent; class UInteractionComponent;
class UInteractionOptionListItem; class UInteractionOptionListItem;
class UInteractionPromptWidget;
class UListView; class UListView;
class UTextBlock;
class UWidget;
/** /**
* One widget covering both interaction UI needs so you only wire up a single thing * The Context Menu shown while holding Interact on a 3+-option IInteractable
* in WBP_PlayerHUD: an ever-present "you're looking at something" prompt (PromptText, * (OptionsList, driven by OnContextMenuOpened/HighlightChanged/Closed). The
* driven by OnFocusedInteractableChanged - shows any time the look trace is on an * ever-present "you're looking at something" prompt lives in a separate
* IInteractable, regardless of option count or whether Interact is even pressed) and * nested UInteractionPromptWidget instance (e.g. WBP_InteractionQuick) -
* the Context Menu shown while holding Interact on a 3+-option IInteractable * BindWidget can only see direct nodes of this widget's own tree, not reach
* (OptionsList, driven by OnContextMenuOpened/HighlightChanged/Closed). Both bound * inside another UserWidget's tree, so that prompt/slider logic can't live
* widgets are optional and toggle their own visibility independently - add whichever * here; InitializeInteraction forwards to it instead, so the HUD only needs
* you want in the Designer, named exactly as below; all population/highlight/show-hide * to call this one entry point.
* logic lives here in C++.
*/ */
UCLASS(abstract) UCLASS(abstract)
class UInteractionContextMenuWidget : public UUserWidget class UInteractionContextMenuWidget : public UUserWidget
@@ -31,7 +29,7 @@ class UInteractionContextMenuWidget : public UUserWidget
public: 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") UFUNCTION(BlueprintCallable, Category = "Interaction")
void InitializeInteraction(UInteractionComponent* InInteractionComponent); void InitializeInteraction(UInteractionComponent* InInteractionComponent);
@@ -45,9 +43,6 @@ protected:
/** Selects ListItems[Index] on OptionsList, which drives each row's IUserObjectListEntry::NativeOnItemSelectionChanged. */ /** Selects ListItems[Index] on OptionsList, which drives each row's IUserObjectListEntry::NativeOnItemSelectionChanged. */
void SelectHighlighted(int32 Index); void SelectHighlighted(int32 Index);
UFUNCTION()
void HandleFocusedInteractableChanged(AActor* FocusedActor, const TArray<FInteractionOption>& Options);
UFUNCTION() UFUNCTION()
void HandleContextMenuOpened(const TArray<FInteractionOption>& Options, int32 InitialHighlightedIndex); void HandleContextMenuOpened(const TArray<FInteractionOption>& Options, int32 InitialHighlightedIndex);
@@ -57,19 +52,6 @@ protected:
UFUNCTION() UFUNCTION()
void HandleContextMenuClosed(); 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<UTextBlock> 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<UWidget> TextContainer;
/** /**
* The Context Menu's option list. Optional, place it in the WBP subclass's Designer * 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 * 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)) UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true))
TObjectPtr<UListView> OptionsList; TObjectPtr<UListView> 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<UInteractionPromptWidget> WBP_InteractionQuick;
UPROPERTY() UPROPERTY()
TArray<FInteractionOption> CachedOptions; TArray<FInteractionOption> CachedOptions;
@@ -88,14 +79,4 @@ protected:
UPROPERTY() UPROPERTY()
TObjectPtr<UInteractionComponent> InteractionComponent; TObjectPtr<UInteractionComponent> 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<AActor> LastFocusedActor;
UPROPERTY()
TArray<FInteractionOption> LastFocusedOptions;
}; };
@@ -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<FInteractionOption>& 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<FInteractionOption>& 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);
}
}
@@ -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<FInteractionOption>& Options);
UFUNCTION()
void HandleContextMenuOpened(const TArray<FInteractionOption>& 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<UTextBlock> 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<UWidget> 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<UTextBlock> 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<USegmentedBarWidget> WBP_InteractionsSlider;
UPROPERTY()
TObjectPtr<UInteractionComponent> InteractionComponent;
private:
/** Rebuilds PromptText from LastFocused*, collapsing it while the Context Menu is open. */
void UpdatePromptVisibility();
TWeakObjectPtr<AActor> LastFocusedActor;
UPROPERTY()
TArray<FInteractionOption> LastFocusedOptions;
};