How-To: Add Holds, Channels & a Progress Prompt
Goal: extend your own interact ability (How-To: Write Your Own Interact Ability) with hold-to-confirm interactions, timed channels (mining, reviving), and an on-screen prompt - still using only CrimsonInteraction.
GA_Interact with CurrentOptions, the Input.Interact.Pressed / Input.Interact.Released events, and a working instant press.1. Hold support
Hold timing lives in ability timers, never in an Enhanced Input Hold trigger (per-option durations are data, and the input layer cannot know what you are aiming at). A repeating timer pushes progress to the target; a one-shot timer fires the interaction when the duration elapses; the release event cancels. Add the members PendingHoldOption, bHoldPending, HoldElapsed, HoldTickHandle, HoldDoneHandle to GA_Interact, branch on InputType in the press handler, and bind OnReleased to the release event from How-To: Write Your Own Interact Ability step 4.
Add five variables to GA_Interact: PendingHoldOption (Crimson Interaction Option), bHoldPending (Boolean), HoldElapsed (Float), and HoldTickHandle / HoldDoneHandle (both Timer Handle). Then split the press path from How-To: Write Your Own Interact Ability step 5:
- After the length guard, insert Get CurrentOptions -> Get (a ref) (
Index=0) -> Break CrimsonInteractionOption -> Equal (Enum) comparingInput TypeagainstHold-> Branch. False keeps the instant Trigger Interaction Option you already wired. - True -> Set PendingHoldOption (the option) -> Set bHoldPending (
true) -> Set HoldElapsed (0.0) -> Set Timer by Event (Time=0.05,Looping= ticked), whose red delegate pin goes to a Custom EventTickHold. Promote the node'sReturn ValueintoHoldTickHandle. - -> Set Timer by Event again (
Time= the broken option'sInput Hold Duration,Looping= unticked), delegate pin to a Custom EventCompleteHold,Return ValueintoHoldDoneHandle. TickHold: Set HoldElapsed (HoldElapsed + 0.05) -> Divide (float) byPendingHoldOption'sInput Hold Duration-> Clamp (float) (Min=0.0,Max=1.0) -> Notify Interaction Progress (interface message call,Target=PendingHoldOption'sInteractable Target,In Instigator= Get Avatar Actor From Actor Info).CompleteHold: Clear and Invalidate Timer by Handle onHoldTickHandle, then onHoldDoneHandle-> Notify Interaction Progress (Progress=1.0) -> Trigger Interaction Option (Interaction Option=PendingHoldOption, and the sameInstigator/Interaction Event Tagas Quick Start step 8) -> Set bHoldPending (false).- The cancel path: from the Wait Gameplay Event (
Input.Interact.Released) task's Event Received pin, Get bHoldPending (Branch, True) -> Clear and Invalidate Timer by Handle on both handles -> Notify Interaction Progress (Progress=0.0) -> Set bHoldPending (false). Pushing0is what resets the target's bar; without it a released hold leaves the fill frozen mid-way.
InputType = Hold, InputHoldDuration = 1.5. Holding fills the target's progress (override Notify Interaction Progress on the interactable to see it); releasing early resets it to 0; completing fires the handler.2. A channeled action phase
For mining / reviving style channels, set the option's InteractionActionAbility to your own channel ability. The grant task pre-grants it automatically (it grants both ability fields), so you launch it the same handle-based way. Give the channel ability its own gameplay-event trigger tag (e.g. Ability.Interaction.Duration.Start - add it like the input tags in How-To: Write Your Own Interact Ability step 1), then route options that carry an InteractionActionAbility to a launcher like this instead of the instant fire:
void UGA_Interact::StartChannel(const FCrimsonInteractionOption& Option){UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo();const FGameplayAbilitySpec* Spec = ASC ? ASC->FindAbilitySpecFromClass(Option.InteractionActionAbility) : nullptr;if (!Spec){return; // grant hasn't replicated yet - ignore the press}FGameplayEventData EventData;EventData.EventTag = FGameplayTag::RequestGameplayTag(FName("Ability.Interaction.Duration.Start"));EventData.Instigator = GetAvatarActorFromActorInfo();EventData.Target = UCrimsonInteractionStatics::GetActorFromInteractableTarget(Option.InteractableTarget);// Pass CLASSES / net-addressable objects, never spec handles - handles are machine-local.EventData.OptionalObject = Option.TargetAbilitySystem;EventData.OptionalObject2 = Option.InteractionAbilityToGrant.Get();ASC->TriggerAbilityFromGameplayEvent(Spec->Handle, GetCurrentActorInfo(),EventData.EventTag, &EventData, *ASC);}
The channel ability itself is an event-triggered ability (constructor identical to the Quick Start handler but with your duration tag) that ticks a timer, pushes progress, and notifies the target on completion. The plugin's UCrimsonInteractionDurationContext gives its widget a ready-made progress channel (OnProgressChanged):
// GA_Mine.cpp - members: float DurationSeconds = 3.f; float Elapsed = 0.f;// TObjectPtr<AActor> Target; TObjectPtr<UCrimsonInteractionDurationContext> DurationContext;// TSubclassOf<UUserWidget> ProgressWidgetClass; FTimerHandle TickHandle;#include "CrimsonInteractionDurationContext.h" // CrimsonInteraction#include "Interaction/ICrimsonInteractableTarget.h" // CrimsonInteractionvoid UGA_Mine::ActivateAbility(const FGameplayAbilitySpecHandle Handle,const FGameplayAbilityActorInfo* ActorInfo,const FGameplayAbilityActivationInfo ActivationInfo,const FGameplayEventData* TriggerEventData){if (!CommitAbility(Handle, ActorInfo, ActivationInfo)){EndAbility(Handle, ActorInfo, ActivationInfo, true, true);return;}Target = TriggerEventData ? const_cast<AActor*>(ToRawPtr(TriggerEventData->Target)) : nullptr;// Optional progress widget: create the context and hand it to your HUD, which spawns ProgressWidgetClass and binds OnProgressChanged:if (ActorInfo->IsLocallyControlled() && ProgressWidgetClass){DurationContext = NewObject<UCrimsonInteractionDurationContext>(this);DurationContext->Construct(ProgressWidgetClass, FText::FromString(TEXT("Mining...")), DurationSeconds);// MyHUD->ShowDurationProgress(DurationContext); - however your HUD places widgets}GetWorld()->GetTimerManager().SetTimer(TickHandle, this, &UGA_Mine::TickChannel, 0.05f, true);}void UGA_Mine::TickChannel(){Elapsed += 0.05f;if (DurationContext) { DurationContext->UpdateProgress(Elapsed, DurationSeconds); }if (Elapsed >= DurationSeconds){GetWorld()->GetTimerManager().ClearTimer(TickHandle);if (Target && Target->Implements<UCrimsonInteractableTarget>()){ICrimsonInteractableTarget::Execute_NotifyInteractionSuccess(Target, GetAvatarActorFromActorInfo());}EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, true, false);}}
TriggerAbilityFromGameplayEvent). Blueprint alternative: give each channel ability a unique gameplay-event trigger tag and launch it with Send Gameplay Event to Actor (Actor = avatar, payload built with Make GameplayEventData, Target = the interactable). The tag must be unique per channel ability - a shared tag would activate every listening ability at once. The channel ability body itself (timers, Update Progress on the duration context, Broadcast Message, Notify Interaction Success) is fully Blueprint-authorable.3. A minimal prompt
You already have both signals: OnOptionsChanged tells you what to show (CurrentOptions[0].Text, or hide when empty), and the duration context's OnProgressChanged carries channel progress (a hold reports through NotifyInteractionProgress on the target, or a delegate you add to your ability). Push the text into any widget you like from the options handler; for progress, bind OnProgressChanged on the context your ability hands the widget (Blueprint: Bind Event to On Progress Changed; C++ below). The context is created on the owning client only, so a listen server never shows other players' progress.
// In your progress widget - members: TObjectPtr<UCrimsonInteractionDurationContext> Context;#include "CrimsonInteractionDurationContext.h" // CrimsonInteractionvoid UMyProgressWidget::SetContext(UCrimsonInteractionDurationContext* InContext){Context = InContext;Context->OnProgressChanged.AddDynamic(this, &UMyProgressWidget::HandleProgress); // HandleProgress is a UFUNCTION}void UMyProgressWidget::HandleProgress(float CurrentProgress, float Duration){SetBarPercent(Duration > 0.f ? FMath::Clamp(CurrentProgress / Duration, 0.f, 1.f) : 1.f);if (CurrentProgress >= Duration) { HideBar(); }}void UMyProgressWidget::NativeDestruct(){if (Context) { Context->OnProgressChanged.RemoveAll(this); }Super::NativeDestruct();}
See also
- How-To: Instant, Hold & Duration Interactions - choosing the right shape per option.
- Concept: Two Activation Methods - the replication rules behind the handle-based launches.
- How-To: World Indicators & Hold-Progress Feedback - richer feedback options.