diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c4c27d0..aa3c1f3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "mcp__unreal-mcp__describe_toolset", "mcp__unreal-mcp__call_tool", - "Bash(python *)" + "Bash(python *)", + "mcp__unreal-mcp__list_toolsets" ] }, "enabledMcpjsonServers": [ diff --git a/Config/DefaultEngine.ini b/Config/DefaultEngine.ini index 631e962..dbdea0f 100644 --- a/Config/DefaultEngine.ini +++ b/Config/DefaultEngine.ini @@ -73,6 +73,9 @@ AppliedTargetedHardwareClass=Desktop DefaultGraphicsPerformance=Maximum AppliedDefaultGraphicsPerformance=Maximum +[/Script/Engine.CollisionProfile] ++DefaultChannelResponses=(Channel=ECC_GameTraceChannel1,Name="Interactable",DefaultResponse=ECR_Block,bTraceType=True,bStaticObject=False) + [/Script/Engine.Engine] +ActiveGameNameRedirects=(OldGameName="TP_ThirdPerson",NewGameName="/Script/KingshearthLegacy") +ActiveGameNameRedirects=(OldGameName="/Script/TP_ThirdPerson",NewGameName="/Script/KingshearthLegacy") diff --git a/Content/Input/Actions/IA_Interact.uasset b/Content/Input/Actions/IA_Interact.uasset new file mode 100644 index 0000000..15ecc1d Binary files /dev/null and b/Content/Input/Actions/IA_Interact.uasset differ diff --git a/Content/Input/Actions/IA_InterectCycle.uasset b/Content/Input/Actions/IA_InterectCycle.uasset new file mode 100644 index 0000000..cc5645a Binary files /dev/null and b/Content/Input/Actions/IA_InterectCycle.uasset differ diff --git a/Content/Input/IMC_Default.uasset b/Content/Input/IMC_Default.uasset index 7935a05..6430975 100644 Binary files a/Content/Input/IMC_Default.uasset and b/Content/Input/IMC_Default.uasset differ diff --git a/Content/Testing/BP_Campfire.uasset b/Content/Testing/BP_Campfire.uasset new file mode 100644 index 0000000..33862bd Binary files /dev/null and b/Content/Testing/BP_Campfire.uasset differ diff --git a/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset b/Content/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.uasset index 590d9db..6623a9b 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_InteractionContextMenu.uasset b/Content/UI/WBP_InteractionContextMenu.uasset new file mode 100644 index 0000000..0d0362f Binary files /dev/null and b/Content/UI/WBP_InteractionContextMenu.uasset differ diff --git a/Content/UI/WBP_PlayerHUD.uasset b/Content/UI/WBP_PlayerHUD.uasset index 653c9ed..3299105 100644 Binary files a/Content/UI/WBP_PlayerHUD.uasset and b/Content/UI/WBP_PlayerHUD.uasset differ diff --git a/Source/KingshearthLegacy/Interaction/Interactable.h b/Source/KingshearthLegacy/Interaction/Interactable.h new file mode 100644 index 0000000..887a4a0 --- /dev/null +++ b/Source/KingshearthLegacy/Interaction/Interactable.h @@ -0,0 +1,37 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Interface.h" +#include "InteractionTypes.h" +#include "Interactable.generated.h" + +/** + * Generic interaction interface. Blueprintable so pure-Blueprint actors + * (BP_DoorFrame, BP_JumpPad, ...) can implement it without any C++. + */ +UINTERFACE(MinimalAPI, Blueprintable) +class UInteractable : public UInterface +{ + GENERATED_BODY() +}; + +/** + * Implement this and return interaction data - the InteractionComponent decides + * on its own whether that means a direct tap, a hold, or a Context Menu. + */ +class IInteractable +{ + GENERATED_BODY() + +public: + + /** Returns the interactions currently available to Interactor, in priority order (index 0 = primary/tap). */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction") + TArray GetInteractionOptions(AActor* Interactor); + + /** Runs the interaction identified by ActionID (one of the ActionIDs previously returned by GetInteractionOptions). */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction") + void ExecuteInteraction(AActor* Interactor, FName ActionID); +}; diff --git a/Source/KingshearthLegacy/Interaction/InteractionComponent.cpp b/Source/KingshearthLegacy/Interaction/InteractionComponent.cpp new file mode 100644 index 0000000..aef11eb --- /dev/null +++ b/Source/KingshearthLegacy/Interaction/InteractionComponent.cpp @@ -0,0 +1,205 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "InteractionComponent.h" +#include "Interactable.h" +#include "InteractionLookupProvider.h" +#include "Engine/World.h" +#include "TimerManager.h" +#include "GameFramework/Actor.h" +#include "GameFramework/Pawn.h" +#include "GameFramework/Controller.h" +#include "DrawDebugHelpers.h" +#include "KingshearthLegacy.h" + +UInteractionComponent::UInteractionComponent() +{ + PrimaryComponentTick.bCanEverTick = true; +} + +void UInteractionComponent::BeginPlay() +{ + Super::BeginPlay(); + UpdateFocus(); +} + +void UInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) +{ + Super::TickComponent(DeltaTime, TickType, ThisTickFunction); + UpdateFocus(); +} + +bool UInteractionComponent::PerformInteractionTrace(FHitResult& OutHit) const +{ + AActor* Owner = GetOwner(); + UWorld* World = GetWorld(); + if (!Owner || !World) + { + return false; + } + + if (Owner->Implements()) + { + if (IInteractionLookupProvider::Execute_InteractionLookupLoop(Owner, OutHit)) + { + return true; + } + } + + FVector TraceStart = Owner->GetActorLocation(); + FRotator TraceRotation = Owner->GetActorRotation(); + + if (const APawn* OwnerPawn = Cast(Owner)) + { + if (const AController* Controller = OwnerPawn->GetController()) + { + Controller->GetPlayerViewPoint(TraceStart, TraceRotation); + } + else + { + TraceStart = OwnerPawn->GetPawnViewLocation(); + TraceRotation = OwnerPawn->GetViewRotation(); + } + } + + const FVector TraceEnd = TraceStart + TraceRotation.Vector() * InteractionRange; + + FCollisionQueryParams QueryParams; + QueryParams.AddIgnoredActor(Owner); + + const bool bHit = World->LineTraceSingleByChannel(OutHit, TraceStart, TraceEnd, InteractionTraceChannel, QueryParams); + +#if ENABLE_DRAW_DEBUG + if (bShowDebugTraces) + { + const bool bHitInteractable = bHit && OutHit.GetActor() && OutHit.GetActor()->Implements(); + DrawDebugLine(World, TraceStart, bHit ? OutHit.Location : TraceEnd, bHitInteractable ? FColor::Green : FColor::Red, false, 0.0f, 0, 1.5f); + if (bHit) + { + DrawDebugSphere(World, OutHit.Location, 8.0f, 12, bHitInteractable ? FColor::Green : FColor::Red, false, 0.0f); + } + } +#endif + + return bHit; +} + +void UInteractionComponent::UpdateFocus() +{ + FHitResult Hit; + const bool bHit = PerformInteractionTrace(Hit); + + AActor* NewFocusedActor = (bHit && Hit.GetActor() && Hit.GetActor()->Implements()) ? Hit.GetActor() : nullptr; + + if (NewFocusedActor == FocusedActor.Get()) + { + return; + } + + FocusedActor = NewFocusedActor; + FocusedOptions = NewFocusedActor ? IInteractable::Execute_GetInteractionOptions(NewFocusedActor, GetOwner()) : TArray(); + +#if ENABLE_DRAW_DEBUG + if (bShowDebugTraces) + { + UE_LOG(LogKingshearthLegacy, Log, TEXT("[Interaction] Focus changed to '%s' (%d option(s))"), *GetNameSafe(NewFocusedActor), FocusedOptions.Num()); + } +#endif + + OnFocusedInteractableChanged.Broadcast(NewFocusedActor, FocusedOptions); +} + +void UInteractionComponent::TryBeginInteract() +{ + UpdateFocus(); + + if (!FocusedActor.IsValid() || FocusedOptions.Num() == 0) + { + return; + } + + CurrentTarget = FocusedActor; + CurrentOptions = FocusedOptions; + HighlightedIndex = 0; + bHoldConsumed = false; + + if (CurrentOptions.Num() >= 2) + { + GetWorld()->GetTimerManager().SetTimer(HoldTimerHandle, this, &UInteractionComponent::HandleHoldThresholdReached, HoldThreshold, false); + } +} + +void UInteractionComponent::TryEndInteract() +{ + GetWorld()->GetTimerManager().ClearTimer(HoldTimerHandle); + + if (bContextMenuOpen) + { + ExecuteOption(HighlightedIndex); + CloseContextMenu(); + } + else if (!bHoldConsumed && CurrentOptions.Num() >= 1) + { + ExecuteOption(0); + } + + ClearCachedTarget(); +} + +void UInteractionComponent::CycleContextMenuOptions(float Direction) +{ + if (!bContextMenuOpen || CurrentOptions.Num() == 0 || FMath::IsNearlyZero(Direction)) + { + return; + } + + const int32 Step = Direction > 0.0f ? 1 : -1; + HighlightedIndex = (HighlightedIndex + Step + CurrentOptions.Num()) % CurrentOptions.Num(); + OnContextMenuHighlightChanged.Broadcast(HighlightedIndex); +} + +void UInteractionComponent::HandleHoldThresholdReached() +{ + bHoldConsumed = true; + + if (CurrentOptions.Num() == 2) + { + ExecuteOption(1); + ClearCachedTarget(); + } + else if (CurrentOptions.Num() >= 3) + { + OpenContextMenu(); + } +} + +void UInteractionComponent::OpenContextMenu() +{ + bContextMenuOpen = true; + HighlightedIndex = 0; + OnContextMenuOpened.Broadcast(CurrentOptions, HighlightedIndex); +} + +void UInteractionComponent::CloseContextMenu() +{ + bContextMenuOpen = false; + OnContextMenuClosed.Broadcast(); +} + +void UInteractionComponent::ExecuteOption(int32 Index) +{ + AActor* Target = CurrentTarget.Get(); + if (!Target || !CurrentOptions.IsValidIndex(Index)) + { + return; + } + + IInteractable::Execute_ExecuteInteraction(Target, GetOwner(), CurrentOptions[Index].ActionID); +} + +void UInteractionComponent::ClearCachedTarget() +{ + CurrentTarget = nullptr; + CurrentOptions.Reset(); + HighlightedIndex = 0; + bHoldConsumed = false; +} diff --git a/Source/KingshearthLegacy/Interaction/InteractionComponent.h b/Source/KingshearthLegacy/Interaction/InteractionComponent.h new file mode 100644 index 0000000..da7f11d --- /dev/null +++ b/Source/KingshearthLegacy/Interaction/InteractionComponent.h @@ -0,0 +1,144 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "InteractionTypes.h" +#include "InteractionComponent.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnFocusedInteractableChanged, AActor*, FocusedActor, const TArray&, FocusedOptions); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnContextMenuOpened, const TArray&, Options, int32, InitialHighlightedIndex); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnContextMenuHighlightChanged, int32, NewHighlightedIndex); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnContextMenuClosed); + +/** + * Drives the whole "hold E" contextual interaction system for its owning Actor: + * every tick it look-traces for an IInteractable, distinguishes tap/hold on the + * currently-focused one, and either executes an interaction directly (1-2 options) + * or drives a Context Menu (3+ options). Interactable objects need to know nothing + * about any of this - they just implement IInteractable and return their own + * FInteractionOption data. + * + * Detection is a single line trace on the "Interactable" trace channel (see + * DefaultEngine.ini - ECC_GameTraceChannel1, default response Block), not a + * proximity sweep. That channel defaults to blocking everything, exactly like + * Visibility, so ordinary geometry occludes it correctly out of the box; to make + * only part of an actor count as its "interactable hit box", set that specific + * component's Interactable trace response and turn the rest of the mesh's off + * (Ignore) in its Collision settings - no C++ changes needed per-actor. + */ +UCLASS(ClassGroup = (Interaction), Blueprintable, meta = (BlueprintSpawnableComponent)) +class KINGSHEARTHLEGACY_API UInteractionComponent : public UActorComponent +{ + GENERATED_BODY() + +public: + + UInteractionComponent(); + + /** Fired every time the look-traced focus changes, including to/from nothing - drives an ever-present "you can interact" prompt. */ + UPROPERTY(BlueprintAssignable, Category = "Interaction") + FOnFocusedInteractableChanged OnFocusedInteractableChanged; + + /** Fired when 3+ options are available and the hold threshold is reached. */ + UPROPERTY(BlueprintAssignable, Category = "Interaction") + FOnContextMenuOpened OnContextMenuOpened; + + /** Fired whenever CycleContextMenuOptions moves the highlighted entry. */ + UPROPERTY(BlueprintAssignable, Category = "Interaction") + FOnContextMenuHighlightChanged OnContextMenuHighlightChanged; + + /** Fired when the Context Menu closes, whether by confirming a choice or losing its target. */ + UPROPERTY(BlueprintAssignable, Category = "Interaction") + FOnContextMenuClosed OnContextMenuClosed; + + /** Call from the Interact input's Started/pressed event. */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void TryBeginInteract(); + + /** Call from the Interact input's Completed/released event. */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void TryEndInteract(); + + /** Call while the Context Menu is open (e.g. from a mouse wheel axis) to move the highlighted entry. No-op otherwise. */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void CycleContextMenuOptions(float Direction); + + UFUNCTION(BlueprintPure, Category = "Interaction") + bool IsContextMenuOpen() const { return bContextMenuOpen; } + + /** The actor currently under the look-trace, or nullptr if none. */ + UFUNCTION(BlueprintPure, Category = "Interaction") + AActor* GetFocusedActor() const { return FocusedActor.Get(); } + +protected: + + virtual void BeginPlay() override; + virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; + + /** Max distance of the look trace. */ + UPROPERTY(EditAnywhere, Category = "Interaction") + float InteractionRange = 300.0f; + + /** Trace channel the look trace queries - see the "Interactable" channel set up in DefaultEngine.ini. */ + UPROPERTY(EditAnywhere, Category = "Interaction") + TEnumAsByte InteractionTraceChannel = ECC_GameTraceChannel1; + + /** Seconds the Interact input must be held before it counts as a hold rather than a tap. */ + UPROPERTY(EditAnywhere, Category = "Interaction") + float HoldThreshold = 0.4f; + + /** Draws the look trace every tick (green = hit an IInteractable, red = blocked/missed) and logs focus changes. */ + UPROPERTY(EditAnywhere, Category = "Interaction|Debug") + bool bShowDebugTraces = false; + + /** + * Finds the look-trace target. If the owning Actor implements IInteractionLookupProvider, + * defers to its InteractionLookupLoop first - implement that interface directly on your + * Character/Actor to fully replace how a target is found (VR hand ray, a cone check, ...) + * without touching the tap/hold/menu logic below. Falls back to a line trace from the + * controller's view point along its view direction, InteractionRange long, on + * InteractionTraceChannel, whenever the interface isn't implemented or returns false. + */ + bool PerformInteractionTrace(FHitResult& OutHit) const; + + /** Re-runs PerformInteractionTrace, updates FocusedActor/FocusedOptions, and broadcasts OnFocusedInteractableChanged if either changed. */ + void UpdateFocus(); + + /** Bound to the hold timer; only ever runs when CurrentOptions.Num() >= 2. */ + void HandleHoldThresholdReached(); + + void OpenContextMenu(); + void CloseContextMenu(); + + /** Executes CurrentOptions[Index] against CurrentTarget, if both are still valid. */ + void ExecuteOption(int32 Index); + + void ClearCachedTarget(); + + /** Actor currently under the look trace and implementing IInteractable, updated every tick by UpdateFocus. */ + UPROPERTY() + TWeakObjectPtr FocusedActor; + + UPROPERTY() + TArray FocusedOptions; + + /** Snapshot of FocusedActor/FocusedOptions taken when Interact was pressed - what TryEndInteract actually acts on. */ + UPROPERTY() + TWeakObjectPtr CurrentTarget; + + UPROPERTY() + TArray CurrentOptions; + + UPROPERTY() + int32 HighlightedIndex = 0; + + UPROPERTY() + bool bContextMenuOpen = false; + + /** Set the moment the hold timer fires, so TryEndInteract knows not to also run the tap path. */ + bool bHoldConsumed = false; + + FTimerHandle HoldTimerHandle; +}; diff --git a/Source/KingshearthLegacy/Interaction/InteractionLookupProvider.h b/Source/KingshearthLegacy/Interaction/InteractionLookupProvider.h new file mode 100644 index 0000000..86fe030 --- /dev/null +++ b/Source/KingshearthLegacy/Interaction/InteractionLookupProvider.h @@ -0,0 +1,33 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Interface.h" +#include "InteractionLookupProvider.generated.h" + +UINTERFACE(MinimalAPI, Blueprintable) +class UInteractionLookupProvider : public UInterface +{ + GENERATED_BODY() +}; + +/** + * Optional interface for the Actor a UInteractionComponent is attached to (e.g. + * BP_ThirdPersonCharacter). Implement it there - Class Settings > Implement Interface, + * just like IInteractable on an interactable object - and override InteractionLookupLoop + * in that Actor's own Event Graph to replace how its "look at" target is found (a + * different origin, a cone check, a VR hand-ray, ...). No separate component subclass + * or class-swapping needed. If not implemented, or the implementation returns false, + * UInteractionComponent falls back to its own default camera-forward line trace. + */ +class IInteractionLookupProvider +{ + GENERATED_BODY() + +public: + + /** Return true and fill OutHit with what was found; return false to fall back to the component's default trace. */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction") + bool InteractionLookupLoop(FHitResult& OutHit); +}; diff --git a/Source/KingshearthLegacy/Interaction/InteractionTypes.h b/Source/KingshearthLegacy/Interaction/InteractionTypes.h new file mode 100644 index 0000000..5765a30 --- /dev/null +++ b/Source/KingshearthLegacy/Interaction/InteractionTypes.h @@ -0,0 +1,25 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "InteractionTypes.generated.h" + +/** + * A single interaction an IInteractable currently offers. Order matters: + * index 0 is always the primary (tap) action, index 1 (if present) is the + * secondary (hold) action, and 3+ entries populate the Context Menu in order. + */ +USTRUCT(BlueprintType) +struct FInteractionOption +{ + GENERATED_BODY() + + /** Shown to the player (Context Menu entry, interaction prompt, ...) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction") + FText DisplayName; + + /** Opaque id passed back to IInteractable::ExecuteInteraction - the object interprets it itself */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction") + FName ActionID; +}; diff --git a/Source/KingshearthLegacy/KingshearthLegacy.Build.cs b/Source/KingshearthLegacy/KingshearthLegacy.Build.cs index d5407f7..136e06a 100644 --- a/Source/KingshearthLegacy/KingshearthLegacy.Build.cs +++ b/Source/KingshearthLegacy/KingshearthLegacy.Build.cs @@ -30,6 +30,7 @@ public class KingshearthLegacy : ModuleRules "KingshearthLegacy", "KingshearthLegacy/AttributeSets", "KingshearthLegacy/GameplayEffects", + "KingshearthLegacy/Interaction", "KingshearthLegacy/UI/Menu", "KingshearthLegacy/Variant_Platforming", "KingshearthLegacy/Variant_Platforming/Animation", diff --git a/Source/KingshearthLegacy/KingshearthLegacyCharacter.cpp b/Source/KingshearthLegacy/KingshearthLegacyCharacter.cpp index 87a2777..7589b26 100644 --- a/Source/KingshearthLegacy/KingshearthLegacyCharacter.cpp +++ b/Source/KingshearthLegacy/KingshearthLegacyCharacter.cpp @@ -19,6 +19,7 @@ #include "GE_HealthDebuff.h" #include "GE_StaminaFatiguePenalty.h" #include "KingshearthLegacyGameplayTags.h" +#include "Interaction/InteractionComponent.h" AKingshearthLegacyCharacter::AKingshearthLegacyCharacter() { @@ -64,6 +65,8 @@ AKingshearthLegacyCharacter::AKingshearthLegacyCharacter() StaminaAttributeSet = CreateDefaultSubobject(TEXT("StaminaAttributeSet")); AbilitySystemComponent->AddAttributeSetSubobject(StaminaAttributeSet.Get()); + + InteractionComponent = CreateDefaultSubobject(TEXT("InteractionComponent")); } void AKingshearthLegacyCharacter::BeginPlay() @@ -276,6 +279,11 @@ void AKingshearthLegacyCharacter::SetupPlayerInputComponent(UInputComponent* Pla // Sprinting EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Started, this, &AKingshearthLegacyCharacter::StartSprint); EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Completed, this, &AKingshearthLegacyCharacter::StopSprint); + + // Interacting + EnhancedInputComponent->BindAction(InteractAction, ETriggerEvent::Started, this, &AKingshearthLegacyCharacter::DoInteractStart); + EnhancedInputComponent->BindAction(InteractAction, ETriggerEvent::Completed, this, &AKingshearthLegacyCharacter::DoInteractEnd); + EnhancedInputComponent->BindAction(InteractCycleAction, ETriggerEvent::Triggered, this, &AKingshearthLegacyCharacter::InteractCycle); } else { @@ -343,6 +351,35 @@ void AKingshearthLegacyCharacter::DoJumpEnd() StopJumping(); } +void AKingshearthLegacyCharacter::DoInteractStart() +{ + if (InteractionComponent) + { + InteractionComponent->TryBeginInteract(); + } +} + +void AKingshearthLegacyCharacter::DoInteractEnd() +{ + if (InteractionComponent) + { + InteractionComponent->TryEndInteract(); + } +} + +void AKingshearthLegacyCharacter::DoInteractCycle(float Direction) +{ + if (InteractionComponent) + { + InteractionComponent->CycleContextMenuOptions(Direction); + } +} + +void AKingshearthLegacyCharacter::InteractCycle(const FInputActionValue& Value) +{ + DoInteractCycle(Value.Get()); +} + void AKingshearthLegacyCharacter::Jump() { const bool bCouldJump = CanJump(); diff --git a/Source/KingshearthLegacy/KingshearthLegacyCharacter.h b/Source/KingshearthLegacy/KingshearthLegacyCharacter.h index 085953c..ce1e31b 100644 --- a/Source/KingshearthLegacy/KingshearthLegacyCharacter.h +++ b/Source/KingshearthLegacy/KingshearthLegacyCharacter.h @@ -15,6 +15,7 @@ class UInputAction; class UAbilitySystemComponent; class UHealthAttributeSet; class UStaminaAttributeSet; +class UInteractionComponent; struct FInputActionValue; DECLARE_LOG_CATEGORY_EXTERN(LogTemplateCharacter, Log, All); @@ -58,6 +59,14 @@ protected: UPROPERTY(EditAnywhere, Category="Input") UInputAction* SprintAction; + /** Interact Input Action - Started begins a tap/hold, Completed resolves it */ + UPROPERTY(EditAnywhere, Category="Input") + UInputAction* InteractAction; + + /** Cycles the Context Menu's highlighted option while it is open (e.g. mouse wheel axis) */ + UPROPERTY(EditAnywhere, Category="Input") + UInputAction* InteractCycleAction; + /** Walk speed is multiplied by this while sprinting */ UPROPERTY(EditAnywhere, Category="Sprint") float SprintSpeedMultiplier = 1.6f; @@ -120,6 +129,9 @@ protected: /** Called when the sprint input is released */ void StopSprint(); + /** Called for Context Menu cycling input (e.g. mouse wheel axis) */ + void InteractCycle(const FInputActionValue& Value); + /** Advances the regen delay/ramp curve and applies Stamina/Fatigue regen for this frame. */ void TickStaminaRegen(float DeltaSeconds); @@ -144,6 +156,10 @@ protected: /** Currently-applied GE_HealthDebuff instance, if any - reapplied/removed wholesale by SetHealthDebuff so it behaves like the old "set to X" API instead of stacking. */ FActiveGameplayEffectHandle HealthDebuffEffectHandle; + /** Drives the contextual "hold E" interaction system - see UInteractionComponent. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Interaction", meta = (AllowPrivateAccess = "true")) + TObjectPtr InteractionComponent; + public: //~ Begin IAbilitySystemInterface @@ -219,6 +235,21 @@ public: UFUNCTION(BlueprintCallable, Category="Input") virtual void DoJumpEnd(); + /** Handles interact pressed inputs from either controls or UI interfaces - begins a tap/hold */ + UFUNCTION(BlueprintCallable, Category="Input") + virtual void DoInteractStart(); + + /** Handles interact released inputs from either controls or UI interfaces - resolves the tap/hold */ + UFUNCTION(BlueprintCallable, Category="Input") + virtual void DoInteractEnd(); + + /** Handles Context Menu cycling inputs (e.g. mouse wheel) from either controls or UI interfaces */ + UFUNCTION(BlueprintCallable, Category="Input") + virtual void DoInteractCycle(float Direction); + + /** Returns InteractionComponent subobject **/ + FORCEINLINE UInteractionComponent* GetInteractionComponent() const { return InteractionComponent; } + public: /** Returns CameraBoom subobject **/ diff --git a/Source/KingshearthLegacy/KingshearthLegacyPlayerController.cpp b/Source/KingshearthLegacy/KingshearthLegacyPlayerController.cpp index f72a280..603d216 100644 --- a/Source/KingshearthLegacy/KingshearthLegacyPlayerController.cpp +++ b/Source/KingshearthLegacy/KingshearthLegacyPlayerController.cpp @@ -102,8 +102,9 @@ void AKingshearthLegacyPlayerController::OnPossess(APawn* InPawn) const AKingshearthLegacyCharacter* PossessedCharacter = Cast(InPawn); UHealthAttributeSet* PossessedHealthAttributeSet = PossessedCharacter ? PossessedCharacter->GetHealthAttributeSet() : nullptr; UStaminaAttributeSet* PossessedStaminaAttributeSet = PossessedCharacter ? PossessedCharacter->GetStaminaAttributeSet() : nullptr; + UInteractionComponent* PossessedInteractionComponent = PossessedCharacter ? PossessedCharacter->GetInteractionComponent() : nullptr; - PlayerHUD->InitializeHUD(PossessedHealthAttributeSet, PossessedStaminaAttributeSet); + PlayerHUD->InitializeHUD(PossessedHealthAttributeSet, PossessedStaminaAttributeSet, PossessedInteractionComponent); } } diff --git a/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.cpp b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.cpp new file mode 100644 index 0000000..d515f73 --- /dev/null +++ b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.cpp @@ -0,0 +1,110 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "InteractionContextMenuWidget.h" +#include "Interaction/InteractionComponent.h" +#include "Components/VerticalBox.h" +#include "Components/TextBlock.h" +#include "Blueprint/WidgetTree.h" +#include "KingshearthLegacy.h" + +void UInteractionContextMenuWidget::NativeConstruct() +{ + Super::NativeConstruct(); + + if (PromptText) + { + PromptText->SetVisibility(ESlateVisibility::Collapsed); + } + + if (OptionsBox) + { + OptionsBox->SetVisibility(ESlateVisibility::Collapsed); + } +} + +void UInteractionContextMenuWidget::InitializeInteraction(UInteractionComponent* InInteractionComponent) +{ + 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); + } + + InteractionComponent = InInteractionComponent; + + 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) +{ + if (!PromptText) + { + return; + } + + if (FocusedActor && Options.Num() > 0) + { + PromptText->SetText(Options[0].DisplayName); + PromptText->SetVisibility(ESlateVisibility::HitTestInvisible); + } + else + { + PromptText->SetVisibility(ESlateVisibility::Collapsed); + } +} + +void UInteractionContextMenuWidget::RefreshOptions(const TArray& Options, int32 HighlightedIndex) +{ + if (!OptionsBox) + { + return; + } + + OptionsBox->ClearChildren(); + + for (int32 Index = 0; Index < Options.Num(); ++Index) + { + UTextBlock* Row = WidgetTree->ConstructWidget(UTextBlock::StaticClass()); + Row->SetText(Options[Index].DisplayName); + Row->SetColorAndOpacity(FSlateColor(Index == HighlightedIndex ? HighlightedColor : NormalColor)); + OptionsBox->AddChildToVerticalBox(Row); + } +} + +void UInteractionContextMenuWidget::HandleContextMenuOpened(const TArray& Options, int32 InitialHighlightedIndex) +{ + UE_LOG(LogKingshearthLegacy, Log, TEXT("[Interaction] Widget received OnContextMenuOpened: %d option(s), OptionsBox bound: %s"), + Options.Num(), OptionsBox ? TEXT("yes") : TEXT("NO - add a VerticalBox named exactly \"OptionsBox\" in this widget's Designer tree")); + + CachedOptions = Options; + RefreshOptions(CachedOptions, InitialHighlightedIndex); + + if (OptionsBox) + { + OptionsBox->SetVisibility(ESlateVisibility::HitTestInvisible); + } +} + +void UInteractionContextMenuWidget::HandleContextMenuHighlightChanged(int32 NewHighlightedIndex) +{ + RefreshOptions(CachedOptions, NewHighlightedIndex); +} + +void UInteractionContextMenuWidget::HandleContextMenuClosed() +{ + CachedOptions.Reset(); + + if (OptionsBox) + { + OptionsBox->ClearChildren(); + OptionsBox->SetVisibility(ESlateVisibility::Collapsed); + } +} diff --git a/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.h b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.h new file mode 100644 index 0000000..99ad1c1 --- /dev/null +++ b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.h @@ -0,0 +1,76 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "Interaction/InteractionTypes.h" +#include "InteractionContextMenuWidget.generated.h" + +class UInteractionComponent; +class UVerticalBox; +class UTextBlock; + +/** + * 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 + * (OptionsBox, 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++. + */ +UCLASS(abstract) +class UInteractionContextMenuWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + + /** Call once, right after creation (see UPlayerHUDWidget::InitializeHUD), to bind to InInteractionComponent's delegates. */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void InitializeInteraction(UInteractionComponent* InInteractionComponent); + +protected: + + virtual void NativeConstruct() override; + + /** Rebuilds OptionsBox's rows from Options, styling HighlightedIndex differently from the rest. */ + void RefreshOptions(const TArray& Options, int32 HighlightedIndex); + + UFUNCTION() + void HandleFocusedInteractableChanged(AActor* FocusedActor, const TArray& Options); + + UFUNCTION() + void HandleContextMenuOpened(const TArray& Options, int32 InitialHighlightedIndex); + + UFUNCTION() + void HandleContextMenuHighlightChanged(int32 NewHighlightedIndex); + + UFUNCTION() + void HandleContextMenuClosed(); + + /** Text color used for the highlighted row. */ + UPROPERTY(EditAnywhere, Category = "Interaction|Style") + FLinearColor HighlightedColor = FLinearColor(1.0f, 0.85f, 0.3f, 1.0f); + + /** Text color used for every other row. */ + UPROPERTY(EditAnywhere, Category = "Interaction|Style") + FLinearColor NormalColor = FLinearColor::White; + + /** 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; + + /** Container the Context Menu's option rows are built into. Optional, place it in the WBP subclass's Designer tree. */ + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr OptionsBox; + + UPROPERTY() + TArray CachedOptions; + + UPROPERTY() + TObjectPtr InteractionComponent; +}; diff --git a/Source/KingshearthLegacy/UI/PlayerHUDWidget.cpp b/Source/KingshearthLegacy/UI/PlayerHUDWidget.cpp index 5e62f88..0056a7f 100644 --- a/Source/KingshearthLegacy/UI/PlayerHUDWidget.cpp +++ b/Source/KingshearthLegacy/UI/PlayerHUDWidget.cpp @@ -1,8 +1,10 @@ #include "PlayerHUDWidget.h" #include "UI/HealthBarWidget.h" #include "UI/StaminaBarWidget.h" +#include "UI/InteractionContextMenuWidget.h" +#include "KingshearthLegacy.h" -void UPlayerHUDWidget::InitializeHUD(UHealthAttributeSet* InHealthAttributeSet, UStaminaAttributeSet* InStaminaAttributeSet) +void UPlayerHUDWidget::InitializeHUD(UHealthAttributeSet* InHealthAttributeSet, UStaminaAttributeSet* InStaminaAttributeSet, UInteractionComponent* InInteractionComponent) { if (HealthBar) { @@ -13,4 +15,11 @@ void UPlayerHUDWidget::InitializeHUD(UHealthAttributeSet* InHealthAttributeSet, { StaminaBar->InitializeStaminaBar(InStaminaAttributeSet); } + + UE_LOG(LogKingshearthLegacy, Log, TEXT("[Interaction] PlayerHUD ContextMenuWidget bound: %s"), ContextMenuWidget ? TEXT("yes") : TEXT("NO - check the widget in ContextMenuWidget is named exactly \"ContextMenuWidget\" in WBP_PlayerHUD's Designer tree")); + + if (ContextMenuWidget) + { + ContextMenuWidget->InitializeInteraction(InInteractionComponent); + } } diff --git a/Source/KingshearthLegacy/UI/PlayerHUDWidget.h b/Source/KingshearthLegacy/UI/PlayerHUDWidget.h index 636915e..ad6b81d 100644 --- a/Source/KingshearthLegacy/UI/PlayerHUDWidget.h +++ b/Source/KingshearthLegacy/UI/PlayerHUDWidget.h @@ -8,6 +8,8 @@ class UHealthBarWidget; class UStaminaBarWidget; class UHealthAttributeSet; class UStaminaAttributeSet; +class UInteractionComponent; +class UInteractionContextMenuWidget; /** * Top-level player HUD. Owns a health bar and a stamina bar and forwards @@ -21,7 +23,7 @@ class UPlayerHUDWidget : public UUserWidget public: UFUNCTION(BlueprintCallable, Category = "HUD") - void InitializeHUD(UHealthAttributeSet* InHealthAttributeSet, UStaminaAttributeSet* InStaminaAttributeSet); + void InitializeHUD(UHealthAttributeSet* InHealthAttributeSet, UStaminaAttributeSet* InStaminaAttributeSet, UInteractionComponent* InInteractionComponent); protected: UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) @@ -29,4 +31,7 @@ protected: UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) TObjectPtr StaminaBar; + + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr ContextMenuWidget; };