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..c969e3e 100644 --- a/Config/DefaultEngine.ini +++ b/Config/DefaultEngine.ini @@ -73,6 +73,65 @@ AppliedTargetedHardwareClass=Desktop DefaultGraphicsPerformance=Maximum AppliedDefaultGraphicsPerformance=Maximum +[/Script/Engine.CollisionProfile] +-Profiles=(Name="NoCollision",CollisionEnabled=NoCollision,ObjectTypeName="WorldStatic",CustomResponses=((Channel="Visibility",Response=ECR_Ignore),(Channel="Camera",Response=ECR_Ignore)),HelpMessage="No collision",bCanModify=False) +-Profiles=(Name="BlockAll",CollisionEnabled=QueryAndPhysics,ObjectTypeName="WorldStatic",CustomResponses=,HelpMessage="WorldStatic object that blocks all actors by default. All new custom channels will use its own default response. ",bCanModify=False) +-Profiles=(Name="OverlapAll",CollisionEnabled=QueryOnly,ObjectTypeName="WorldStatic",CustomResponses=((Channel="WorldStatic",Response=ECR_Overlap),(Channel="Pawn",Response=ECR_Overlap),(Channel="Visibility",Response=ECR_Overlap),(Channel="WorldDynamic",Response=ECR_Overlap),(Channel="Camera",Response=ECR_Overlap),(Channel="PhysicsBody",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Destructible",Response=ECR_Overlap)),HelpMessage="WorldStatic object that overlaps all actors by default. All new custom channels will use its own default response. ",bCanModify=False) +-Profiles=(Name="BlockAllDynamic",CollisionEnabled=QueryAndPhysics,ObjectTypeName="WorldDynamic",CustomResponses=,HelpMessage="WorldDynamic object that blocks all actors by default. All new custom channels will use its own default response. ",bCanModify=False) +-Profiles=(Name="OverlapAllDynamic",CollisionEnabled=QueryOnly,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="WorldStatic",Response=ECR_Overlap),(Channel="Pawn",Response=ECR_Overlap),(Channel="Visibility",Response=ECR_Overlap),(Channel="WorldDynamic",Response=ECR_Overlap),(Channel="Camera",Response=ECR_Overlap),(Channel="PhysicsBody",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Destructible",Response=ECR_Overlap)),HelpMessage="WorldDynamic object that overlaps all actors by default. All new custom channels will use its own default response. ",bCanModify=False) +-Profiles=(Name="IgnoreOnlyPawn",CollisionEnabled=QueryOnly,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="Pawn",Response=ECR_Ignore),(Channel="Vehicle",Response=ECR_Ignore)),HelpMessage="WorldDynamic object that ignores Pawn and Vehicle. All other channels will be set to default.",bCanModify=False) +-Profiles=(Name="OverlapOnlyPawn",CollisionEnabled=QueryOnly,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="Pawn",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Camera",Response=ECR_Ignore)),HelpMessage="WorldDynamic object that overlaps Pawn, Camera, and Vehicle. All other channels will be set to default. ",bCanModify=False) +-Profiles=(Name="Pawn",CollisionEnabled=QueryAndPhysics,ObjectTypeName="Pawn",CustomResponses=((Channel="Visibility",Response=ECR_Ignore)),HelpMessage="Pawn object. Can be used for capsule of any playerable character or AI. ",bCanModify=False) +-Profiles=(Name="Spectator",CollisionEnabled=QueryOnly,ObjectTypeName="Pawn",CustomResponses=((Channel="WorldStatic",Response=ECR_Block),(Channel="Pawn",Response=ECR_Ignore),(Channel="Visibility",Response=ECR_Ignore),(Channel="WorldDynamic",Response=ECR_Ignore),(Channel="Camera",Response=ECR_Ignore),(Channel="PhysicsBody",Response=ECR_Ignore),(Channel="Vehicle",Response=ECR_Ignore),(Channel="Destructible",Response=ECR_Ignore)),HelpMessage="Pawn object that ignores all other actors except WorldStatic.",bCanModify=False) +-Profiles=(Name="CharacterMesh",CollisionEnabled=QueryOnly,ObjectTypeName="Pawn",CustomResponses=((Channel="Pawn",Response=ECR_Ignore),(Channel="Vehicle",Response=ECR_Ignore),(Channel="Visibility",Response=ECR_Ignore)),HelpMessage="Pawn object that is used for Character Mesh. All other channels will be set to default.",bCanModify=False) +-Profiles=(Name="PhysicsActor",CollisionEnabled=QueryAndPhysics,ObjectTypeName="PhysicsBody",CustomResponses=,HelpMessage="Simulating actors",bCanModify=False) +-Profiles=(Name="Destructible",CollisionEnabled=QueryAndPhysics,ObjectTypeName="Destructible",CustomResponses=,HelpMessage="Destructible actors",bCanModify=False) +-Profiles=(Name="InvisibleWall",CollisionEnabled=QueryAndPhysics,ObjectTypeName="WorldStatic",CustomResponses=((Channel="Visibility",Response=ECR_Ignore)),HelpMessage="WorldStatic object that is invisible.",bCanModify=False) +-Profiles=(Name="InvisibleWallDynamic",CollisionEnabled=QueryAndPhysics,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="Visibility",Response=ECR_Ignore)),HelpMessage="WorldDynamic object that is invisible.",bCanModify=False) +-Profiles=(Name="Trigger",CollisionEnabled=QueryOnly,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="WorldStatic",Response=ECR_Overlap),(Channel="Pawn",Response=ECR_Overlap),(Channel="Visibility",Response=ECR_Ignore),(Channel="WorldDynamic",Response=ECR_Overlap),(Channel="Camera",Response=ECR_Overlap),(Channel="PhysicsBody",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Destructible",Response=ECR_Overlap)),HelpMessage="WorldDynamic object that is used for trigger. All other channels will be set to default.",bCanModify=False) +-Profiles=(Name="Ragdoll",CollisionEnabled=QueryAndPhysics,ObjectTypeName="PhysicsBody",CustomResponses=((Channel="Pawn",Response=ECR_Ignore),(Channel="Visibility",Response=ECR_Ignore)),HelpMessage="Simulating Skeletal Mesh Component. All other channels will be set to default.",bCanModify=False) +-Profiles=(Name="Vehicle",CollisionEnabled=QueryAndPhysics,ObjectTypeName="Vehicle",CustomResponses=,HelpMessage="Vehicle object that blocks Vehicle, WorldStatic, and WorldDynamic. All other channels will be set to default.",bCanModify=False) +-Profiles=(Name="UI",CollisionEnabled=QueryOnly,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="WorldStatic",Response=ECR_Overlap),(Channel="Pawn",Response=ECR_Overlap),(Channel="Visibility",Response=ECR_Block),(Channel="WorldDynamic",Response=ECR_Overlap),(Channel="Camera",Response=ECR_Overlap),(Channel="PhysicsBody",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Destructible",Response=ECR_Overlap)),HelpMessage="WorldStatic object that overlaps all actors by default. All new custom channels will use its own default response. ",bCanModify=False) +-Profiles=(Name="WaterBodyCollision",CollisionEnabled=QueryOnly,bCanModify=False,ObjectTypeName="",CustomResponses=((Channel="WorldDynamic",Response=ECR_Overlap),(Channel="Pawn",Response=ECR_Overlap),(Channel="Visibility",Response=ECR_Ignore),(Channel="Camera",Response=ECR_Ignore),(Channel="PhysicsBody",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Destructible",Response=ECR_Overlap)),HelpMessage="Default Water Collision Profile (Created by Water Plugin)") ++Profiles=(Name="NoCollision",CollisionEnabled=NoCollision,bCanModify=False,ObjectTypeName="WorldStatic",CustomResponses=((Channel="Visibility",Response=ECR_Ignore),(Channel="Camera",Response=ECR_Ignore)),HelpMessage="No collision") ++Profiles=(Name="BlockAll",CollisionEnabled=QueryAndPhysics,bCanModify=False,ObjectTypeName="WorldStatic",CustomResponses=,HelpMessage="WorldStatic object that blocks all actors by default. All new custom channels will use its own default response. ") ++Profiles=(Name="OverlapAll",CollisionEnabled=QueryOnly,bCanModify=False,ObjectTypeName="WorldStatic",CustomResponses=((Channel="WorldStatic",Response=ECR_Overlap),(Channel="Pawn",Response=ECR_Overlap),(Channel="Visibility",Response=ECR_Overlap),(Channel="WorldDynamic",Response=ECR_Overlap),(Channel="Camera",Response=ECR_Overlap),(Channel="PhysicsBody",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Destructible",Response=ECR_Overlap)),HelpMessage="WorldStatic object that overlaps all actors by default. All new custom channels will use its own default response. ") ++Profiles=(Name="BlockAllDynamic",CollisionEnabled=QueryAndPhysics,bCanModify=False,ObjectTypeName="WorldDynamic",CustomResponses=,HelpMessage="WorldDynamic object that blocks all actors by default. All new custom channels will use its own default response. ") ++Profiles=(Name="OverlapAllDynamic",CollisionEnabled=QueryOnly,bCanModify=False,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="WorldStatic",Response=ECR_Overlap),(Channel="Pawn",Response=ECR_Overlap),(Channel="Visibility",Response=ECR_Overlap),(Channel="WorldDynamic",Response=ECR_Overlap),(Channel="Camera",Response=ECR_Overlap),(Channel="PhysicsBody",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Destructible",Response=ECR_Overlap)),HelpMessage="WorldDynamic object that overlaps all actors by default. All new custom channels will use its own default response. ") ++Profiles=(Name="IgnoreOnlyPawn",CollisionEnabled=QueryOnly,bCanModify=False,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="Pawn",Response=ECR_Ignore),(Channel="Vehicle",Response=ECR_Ignore)),HelpMessage="WorldDynamic object that ignores Pawn and Vehicle. All other channels will be set to default.") ++Profiles=(Name="OverlapOnlyPawn",CollisionEnabled=QueryOnly,bCanModify=False,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="Pawn",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Camera",Response=ECR_Ignore)),HelpMessage="WorldDynamic object that overlaps Pawn, Camera, and Vehicle. All other channels will be set to default. ") ++Profiles=(Name="Pawn",CollisionEnabled=QueryAndPhysics,bCanModify=False,ObjectTypeName="Pawn",CustomResponses=((Channel="Visibility",Response=ECR_Ignore)),HelpMessage="Pawn object. Can be used for capsule of any playerable character or AI. ") ++Profiles=(Name="Spectator",CollisionEnabled=QueryOnly,bCanModify=False,ObjectTypeName="Pawn",CustomResponses=((Channel="WorldStatic"),(Channel="Pawn",Response=ECR_Ignore),(Channel="Visibility",Response=ECR_Ignore),(Channel="WorldDynamic",Response=ECR_Ignore),(Channel="Camera",Response=ECR_Ignore),(Channel="PhysicsBody",Response=ECR_Ignore),(Channel="Vehicle",Response=ECR_Ignore),(Channel="Destructible",Response=ECR_Ignore)),HelpMessage="Pawn object that ignores all other actors except WorldStatic.") ++Profiles=(Name="CharacterMesh",CollisionEnabled=QueryOnly,bCanModify=False,ObjectTypeName="Pawn",CustomResponses=((Channel="Pawn",Response=ECR_Ignore),(Channel="Vehicle",Response=ECR_Ignore),(Channel="Visibility",Response=ECR_Ignore)),HelpMessage="Pawn object that is used for Character Mesh. All other channels will be set to default.") ++Profiles=(Name="PhysicsActor",CollisionEnabled=QueryAndPhysics,bCanModify=False,ObjectTypeName="PhysicsBody",CustomResponses=,HelpMessage="Simulating actors") ++Profiles=(Name="Destructible",CollisionEnabled=QueryAndPhysics,bCanModify=False,ObjectTypeName="Destructible",CustomResponses=,HelpMessage="Destructible actors") ++Profiles=(Name="InvisibleWall",CollisionEnabled=QueryAndPhysics,bCanModify=False,ObjectTypeName="WorldStatic",CustomResponses=((Channel="Visibility",Response=ECR_Ignore)),HelpMessage="WorldStatic object that is invisible.") ++Profiles=(Name="InvisibleWallDynamic",CollisionEnabled=QueryAndPhysics,bCanModify=False,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="Visibility",Response=ECR_Ignore)),HelpMessage="WorldDynamic object that is invisible.") ++Profiles=(Name="Trigger",CollisionEnabled=QueryOnly,bCanModify=False,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="WorldStatic",Response=ECR_Overlap),(Channel="Pawn",Response=ECR_Overlap),(Channel="Visibility",Response=ECR_Ignore),(Channel="WorldDynamic",Response=ECR_Overlap),(Channel="Camera",Response=ECR_Overlap),(Channel="PhysicsBody",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Destructible",Response=ECR_Overlap)),HelpMessage="WorldDynamic object that is used for trigger. All other channels will be set to default.") ++Profiles=(Name="Ragdoll",CollisionEnabled=QueryAndPhysics,bCanModify=False,ObjectTypeName="PhysicsBody",CustomResponses=((Channel="Pawn",Response=ECR_Ignore),(Channel="Visibility",Response=ECR_Ignore)),HelpMessage="Simulating Skeletal Mesh Component. All other channels will be set to default.") ++Profiles=(Name="Vehicle",CollisionEnabled=QueryAndPhysics,bCanModify=False,ObjectTypeName="Vehicle",CustomResponses=,HelpMessage="Vehicle object that blocks Vehicle, WorldStatic, and WorldDynamic. All other channels will be set to default.") ++Profiles=(Name="UI",CollisionEnabled=QueryOnly,bCanModify=False,ObjectTypeName="WorldDynamic",CustomResponses=((Channel="WorldStatic",Response=ECR_Overlap),(Channel="Pawn",Response=ECR_Overlap),(Channel="Visibility"),(Channel="WorldDynamic",Response=ECR_Overlap),(Channel="Camera",Response=ECR_Overlap),(Channel="PhysicsBody",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Destructible",Response=ECR_Overlap)),HelpMessage="WorldStatic object that overlaps all actors by default. All new custom channels will use its own default response. ") ++Profiles=(Name="WaterBodyCollision",CollisionEnabled=QueryOnly,bCanModify=False,ObjectTypeName="",CustomResponses=((Channel="WorldDynamic",Response=ECR_Overlap),(Channel="Pawn",Response=ECR_Overlap),(Channel="Visibility",Response=ECR_Ignore),(Channel="Camera",Response=ECR_Ignore),(Channel="PhysicsBody",Response=ECR_Overlap),(Channel="Vehicle",Response=ECR_Overlap),(Channel="Destructible",Response=ECR_Overlap)),HelpMessage="Default Water Collision Profile (Created by Water Plugin)") ++DefaultChannelResponses=(Channel=ECC_GameTraceChannel1,DefaultResponse=ECR_Block,bTraceType=True,bStaticObject=False,Name="Interactable") +-ProfileRedirects=(OldName="BlockingVolume",NewName="InvisibleWall") +-ProfileRedirects=(OldName="InterpActor",NewName="IgnoreOnlyPawn") +-ProfileRedirects=(OldName="StaticMeshComponent",NewName="BlockAllDynamic") +-ProfileRedirects=(OldName="SkeletalMeshActor",NewName="PhysicsActor") +-ProfileRedirects=(OldName="InvisibleActor",NewName="InvisibleWallDynamic") ++ProfileRedirects=(OldName="BlockingVolume",NewName="InvisibleWall") ++ProfileRedirects=(OldName="InterpActor",NewName="IgnoreOnlyPawn") ++ProfileRedirects=(OldName="StaticMeshComponent",NewName="BlockAllDynamic") ++ProfileRedirects=(OldName="SkeletalMeshActor",NewName="PhysicsActor") ++ProfileRedirects=(OldName="InvisibleActor",NewName="InvisibleWallDynamic") +-CollisionChannelRedirects=(OldName="Static",NewName="WorldStatic") +-CollisionChannelRedirects=(OldName="Dynamic",NewName="WorldDynamic") +-CollisionChannelRedirects=(OldName="VehicleMovement",NewName="Vehicle") +-CollisionChannelRedirects=(OldName="PawnMovement",NewName="Pawn") ++CollisionChannelRedirects=(OldName="Static",NewName="WorldStatic") ++CollisionChannelRedirects=(OldName="Dynamic",NewName="WorldDynamic") ++CollisionChannelRedirects=(OldName="VehicleMovement",NewName="Vehicle") ++CollisionChannelRedirects=(OldName="PawnMovement",NewName="Pawn") + [/Script/Engine.Engine] +ActiveGameNameRedirects=(OldGameName="TP_ThirdPerson",NewGameName="/Script/KingshearthLegacy") +ActiveGameNameRedirects=(OldGameName="/Script/TP_ThirdPerson",NewGameName="/Script/KingshearthLegacy") diff --git a/Config/DefaultGame.ini b/Config/DefaultGame.ini index a9f6caa..29bf27f 100644 --- a/Config/DefaultGame.ini +++ b/Config/DefaultGame.ini @@ -1,8 +1,9 @@ [/Script/EngineSettings.GeneralProjectSettings] ProjectID=CE954E444D2A872317AAC38182169D03 -ProjectName=Third Person Game Template +ProjectName=Kingsharth Legacy [ConsoleVariables] CommonUI.CheckKeyboardFocusAndParentage=1 CommonUI.DisallowUserFocusedWidgetForPendingFocusRecipient=1 CommonUI.FallbackToDesiredOnAutoRestoreFailure=1 + 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_BerryBush.uasset b/Content/Testing/BP_BerryBush.uasset new file mode 100644 index 0000000..229d68c Binary files /dev/null and b/Content/Testing/BP_BerryBush.uasset differ diff --git a/Content/Testing/BP_Campfire.uasset b/Content/Testing/BP_Campfire.uasset new file mode 100644 index 0000000..4058f95 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..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 new file mode 100644 index 0000000..4850b41 Binary files /dev/null and b/Content/UI/WBP_InteractionContextMenu.uasset differ diff --git a/Content/UI/WBP_InteractionOptionEntry.uasset b/Content/UI/WBP_InteractionOptionEntry.uasset new file mode 100644 index 0000000..9e34e30 Binary files /dev/null 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..57974fc Binary files /dev/null and b/Content/UI/WBP_InteractionQuick.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..3500241 --- /dev/null +++ b/Source/KingshearthLegacy/Interaction/Interactable.h @@ -0,0 +1,41 @@ +// 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); + + /** Friendly object name for prompts, e.g. "Berry Bush", "Wooden Chest", "Workbench". Optional - an empty default just drops the "with X" part of the prompt. */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction") + FText GetInteractableDisplayName(); + + /** 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..4f85289 --- /dev/null +++ b/Source/KingshearthLegacy/Interaction/InteractionComponent.cpp @@ -0,0 +1,258 @@ +// 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(); + UpdateHoldProgress(); +} + +bool UInteractionComponent::PerformInteractionTrace(FHitResult& OutHit) const +{ + AActor* Owner = GetOwner(); + UWorld* World = GetWorld(); + if (!Owner || !World) + { + return false; + } + + if (Owner->Implements()) + { + // Authoritative once implemented - a miss here means "nothing found", full stop. + // Don't silently fall through to the default trace below just because this one + // came back empty; that would use a completely different (un-offset) trace and + // could contradict what the override deliberately decided. + return IInteractionLookupProvider::Execute_InteractionLookupLoop(Owner, OutHit); + } + + 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); + const bool bImplementsInteractable = bHit && Hit.GetActor() && Hit.GetActor()->Implements(); + AActor* NewFocusedActor = bImplementsInteractable ? Hit.GetActor() : nullptr; + +#if ENABLE_DRAW_DEBUG + if (bShowDebugTraces) + { + UE_LOG(LogKingshearthLegacy, Log, TEXT("[Interaction] Tick: bHit=%s hitActor='%s' implementsInteractable=%s currentFocused='%s'"), + bHit ? TEXT("true") : TEXT("false"), + *GetNameSafe(bHit ? Hit.GetActor() : nullptr), + bImplementsInteractable ? TEXT("true") : TEXT("false"), + *GetNameSafe(FocusedActor.Get())); + } +#endif + + 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::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(); + + 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); + RefreshFocusedOptions(); + CloseContextMenu(); + } + else if (!bHoldConsumed && CurrentOptions.Num() >= 1) + { + ExecuteOption(0); + RefreshFocusedOptions(); + } + + ClearCachedTarget(); +} + +void UInteractionComponent::CycleContextMenuOptions(float Direction) +{ + if (!bContextMenuOpen || CurrentOptions.Num() == 0 || FMath::IsNearlyZero(Direction)) + { + return; + } + + // Scrolling up (positive Direction) moves the highlight toward the top of the list (index 0), + // matching how the mouse wheel scrolls content elsewhere - hence the flipped sign here. + const int32 Step = Direction > 0.0f ? -1 : 1; + const int32 NewHighlightedIndex = FMath::Clamp(HighlightedIndex + Step, 0, CurrentOptions.Num() - 1); + + if (NewHighlightedIndex == HighlightedIndex) + { + return; + } + + HighlightedIndex = NewHighlightedIndex; + OnContextMenuHighlightChanged.Broadcast(HighlightedIndex); +} + +void UInteractionComponent::HandleHoldThresholdReached() +{ + bHoldConsumed = true; + + if (CurrentOptions.Num() == 2) + { + ExecuteOption(1); + RefreshFocusedOptions(); + 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::RefreshFocusedOptions() +{ + AActor* Actor = FocusedActor.Get(); + if (!Actor) + { + return; + } + + FocusedOptions = IInteractable::Execute_GetInteractionOptions(Actor, GetOwner()); + OnFocusedInteractableChanged.Broadcast(Actor, FocusedOptions); +} + +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..27163b7 --- /dev/null +++ b/Source/KingshearthLegacy/Interaction/InteractionComponent.h @@ -0,0 +1,171 @@ +// 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); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnInteractHoldProgressChanged, float, Progress); + +/** + * 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; + + /** + * 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(); + + /** 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(); + + /** 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(); + + void OpenContextMenu(); + void CloseContextMenu(); + + /** Executes CurrentOptions[Index] against CurrentTarget, if both are still valid. */ + void ExecuteOption(int32 Index); + + /** + * Re-pulls GetInteractionOptions() for the still-focused actor and re-broadcasts + * OnFocusedInteractableChanged. Call right after ExecuteOption - the interaction just run + * may have changed what that same actor now offers (e.g. a chest that's now empty), and + * UpdateFocus alone would never notice since it only re-pulls options when the focused + * actor's identity changes, not when its offered options do. No-op if nothing is focused. + */ + void RefreshFocusedOptions(); + + 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; + + /** Last value passed to OnInteractHoldProgressChanged, so UpdateHoldProgress only broadcasts on actual change. */ + float LastBroadcastHoldProgress = 0.0f; +}; 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..47d95cb --- /dev/null +++ b/Source/KingshearthLegacy/Interaction/InteractionTypes.h @@ -0,0 +1,58 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "InteractionTypes.generated.h" + +class UTexture2D; + +/** + * 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; + + /** Optional - shown by UInteractionOptionEntryWidget's Icon image when set, hidden otherwise. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction") + TObjectPtr Icon = nullptr; + + /** + * Optional - when this is the primary (index 0) option and this is set, the ever-present + * prompt's first line uses this verbatim instead of building "{DisplayName} with + * {ObjectName}" - e.g. a chest can set this to "Open Chest" instead of ending up as + * "Open with Chest". The "Press E" / "Hold E for ..." lines are unaffected. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction") + FText PromptOverride; + + /** + * Optional per-option override for the Context Menu row's text color (both normal and + * highlighted state use this same color, so the indication doesn't disappear on hover) - + * e.g. red for an action you currently can't perform, gold for a special one. Overrides + * UInteractionOptionEntryWidget's NormalTextColor/HighlightedTextColor for this option + * only. Alpha 0 (the default) means "use the entry widget's own colors". + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction") + FLinearColor TextColorOverride = FLinearColor(0.0f, 0.0f, 0.0f, 0.0f); + + /** + * Optional per-option override for the Context Menu row's highlight tint - overrides + * UInteractionOptionEntryWidget's HighlightColor for this option only. Alpha 0 (the + * default) means "use the entry widget's own color". + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction") + FLinearColor HighlightColorOverride = FLinearColor(0.0f, 0.0f, 0.0f, 0.0f); +}; diff --git a/Source/KingshearthLegacy/KingshearthLegacy.Build.cs b/Source/KingshearthLegacy/KingshearthLegacy.Build.cs index d5407f7..d53dece 100644 --- a/Source/KingshearthLegacy/KingshearthLegacy.Build.cs +++ b/Source/KingshearthLegacy/KingshearthLegacy.Build.cs @@ -19,6 +19,7 @@ public class KingshearthLegacy : ModuleRules "GameplayStateTreeModule", "UMG", "Slate", + "SlateCore", "GameplayAbilities", "GameplayTags", "GameplayTasks" @@ -30,6 +31,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..9d945e6 --- /dev/null +++ b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.cpp @@ -0,0 +1,119 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "InteractionContextMenuWidget.h" +#include "InteractionOptionListItem.h" +#include "InteractionPromptWidget.h" +#include "Interaction/InteractionComponent.h" +#include "Components/ListView.h" +#include "KingshearthLegacy.h" + +void UInteractionContextMenuWidget::NativeConstruct() +{ + Super::NativeConstruct(); + + if (OptionsList) + { + OptionsList->SetVisibility(ESlateVisibility::Collapsed); + } +} + +void UInteractionContextMenuWidget::InitializeInteraction(UInteractionComponent* InInteractionComponent) +{ + if (InteractionComponent) + { + InteractionComponent->OnContextMenuOpened.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuOpened); + InteractionComponent->OnContextMenuHighlightChanged.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuHighlightChanged); + InteractionComponent->OnContextMenuClosed.RemoveDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuClosed); + } + + InteractionComponent = InInteractionComponent; + + if (InteractionComponent) + { + InteractionComponent->OnContextMenuOpened.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuOpened); + InteractionComponent->OnContextMenuHighlightChanged.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuHighlightChanged); + InteractionComponent->OnContextMenuClosed.AddDynamic(this, &UInteractionContextMenuWidget::HandleContextMenuClosed); + } + + 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")); + + if (WBP_InteractionQuick) + { + WBP_InteractionQuick->InitializeInteraction(InInteractionComponent); + } +} + +void UInteractionContextMenuWidget::PopulateOptionsList(const TArray& Options) +{ + if (!OptionsList) + { + return; + } + + OptionsList->ClearListItems(); + ListItems.Reset(Options.Num()); + + TArray ItemObjects; + ItemObjects.Reserve(Options.Num()); + + for (const FInteractionOption& OptionEntry : Options) + { + UInteractionOptionListItem* Item = NewObject(this); + Item->Option = OptionEntry; + ListItems.Add(Item); + ItemObjects.Add(Item); + } + + OptionsList->SetListItems(ItemObjects); +} + +void UInteractionContextMenuWidget::SelectHighlighted(int32 Index) +{ + if (!OptionsList) + { + return; + } + + if (ListItems.IsValidIndex(Index)) + { + OptionsList->SetSelectedItem(ListItems[Index]); + } + else + { + OptionsList->ClearSelection(); + } +} + +void UInteractionContextMenuWidget::HandleContextMenuOpened(const TArray& Options, int32 InitialHighlightedIndex) +{ + UE_LOG(LogKingshearthLegacy, Log, TEXT("[Interaction] Widget received OnContextMenuOpened: %d option(s), OptionsList bound: %s"), + Options.Num(), OptionsList ? TEXT("yes") : TEXT("NO - add a ListView named exactly \"OptionsList\" in this widget's Designer tree")); + + CachedOptions = Options; + PopulateOptionsList(CachedOptions); + SelectHighlighted(InitialHighlightedIndex); + + if (OptionsList) + { + OptionsList->SetVisibility(ESlateVisibility::Visible); + } +} + +void UInteractionContextMenuWidget::HandleContextMenuHighlightChanged(int32 NewHighlightedIndex) +{ + SelectHighlighted(NewHighlightedIndex); +} + +void UInteractionContextMenuWidget::HandleContextMenuClosed() +{ + CachedOptions.Reset(); + ListItems.Reset(); + + if (OptionsList) + { + OptionsList->ClearListItems(); + OptionsList->SetVisibility(ESlateVisibility::Collapsed); + } +} diff --git a/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.h b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.h new file mode 100644 index 0000000..7fa15c6 --- /dev/null +++ b/Source/KingshearthLegacy/UI/InteractionContextMenuWidget.h @@ -0,0 +1,82 @@ +// 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 UInteractionOptionListItem; +class UInteractionPromptWidget; +class UListView; + +/** + * 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 +{ + GENERATED_BODY() + +public: + + /** 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); + +protected: + + virtual void NativeConstruct() override; + + /** Rebuilds OptionsList's items from Options; ListItems is kept alive here so the ListView's UObjects don't get GC'd. */ + void PopulateOptionsList(const TArray& Options); + + /** Selects ListItems[Index] on OptionsList, which drives each row's IUserObjectListEntry::NativeOnItemSelectionChanged. */ + void SelectHighlighted(int32 Index); + + UFUNCTION() + void HandleContextMenuOpened(const TArray& Options, int32 InitialHighlightedIndex); + + UFUNCTION() + void HandleContextMenuHighlightChanged(int32 NewHighlightedIndex); + + UFUNCTION() + void HandleContextMenuClosed(); + + /** + * 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 + * subclass of UInteractionOptionEntryWidget - that class receives each row's data + * and highlighted state natively, no BP event graph logic needed. + */ + 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; + + /** UObject wrappers backing OptionsList - one per CachedOptions entry, rebuilt in PopulateOptionsList. */ + UPROPERTY() + TArray> ListItems; + + UPROPERTY() + TObjectPtr InteractionComponent; +}; diff --git a/Source/KingshearthLegacy/UI/InteractionOptionEntryWidget.cpp b/Source/KingshearthLegacy/UI/InteractionOptionEntryWidget.cpp new file mode 100644 index 0000000..b8c5fe1 --- /dev/null +++ b/Source/KingshearthLegacy/UI/InteractionOptionEntryWidget.cpp @@ -0,0 +1,116 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "InteractionOptionEntryWidget.h" +#include "InteractionOptionListItem.h" +#include "Components/Border.h" +#include "Components/Image.h" +#include "Components/TextBlock.h" +#include "Components/Widget.h" + +void UInteractionOptionEntryWidget::NativeConstruct() +{ + Super::NativeConstruct(); + + // Capture Background's Designer-authored color before ApplyOptionVisuals ever runs, so + // "no override" can fall back to it later instead of an arbitrary hardcoded default. + if (UImage* BackgroundImage = Cast(Background.Get())) + { + DefaultBackgroundColor = BackgroundImage->GetColorAndOpacity(); + } + else if (UBorder* BackgroundBorder = Cast(Background.Get())) + { + DefaultBackgroundColor = BackgroundBorder->GetBrushColor(); + } + + // The ListView only calls NativeOnItemSelectionChanged when a row's selection state + // actually changes, so a freshly-spawned row that has never been (de)selected would + // otherwise keep whatever visibility Highlight has in the Designer - force it into + // the correct "not highlighted" state right away instead. + ApplyHighlightVisuals(); +} + +void UInteractionOptionEntryWidget::NativeOnListItemObjectSet(UObject* ListItemObject) +{ + if (const UInteractionOptionListItem* ListItem = Cast(ListItemObject)) + { + Option = ListItem->Option; + ApplyOptionVisuals(); + + // SListView recycles row widgets between items; a row reassigned from a previously- + // selected item to a new one doesn't necessarily get a fresh NativeOnItemSelectionChanged + // call in the same pass, so re-derive the real selection state here rather than trusting + // whatever bHighlighted this (possibly recycled) widget already had. + bHighlighted = IsListItemSelected(); + ApplyHighlightVisuals(); + } +} + +void UInteractionOptionEntryWidget::NativeOnItemSelectionChanged(bool bIsSelected) +{ + bHighlighted = bIsSelected; + ApplyHighlightVisuals(); +} + +void UInteractionOptionEntryWidget::ApplyOptionVisuals() +{ + if (InteractionText) + { + InteractionText->SetText(Option.DisplayName); + } + + if (Icon) + { + if (Option.Icon) + { + Icon->SetBrushFromTexture(Option.Icon); + Icon->SetVisibility(ESlateVisibility::HitTestInvisible); + } + else + { + Icon->SetVisibility(ESlateVisibility::Collapsed); + } + } + + if (Background) + { + const FLinearColor ResolvedBackgroundColor = Option.HighlightColorOverride.A > 0.0f ? Option.HighlightColorOverride : DefaultBackgroundColor; + + if (UImage* BackgroundImage = Cast(Background.Get())) + { + BackgroundImage->SetColorAndOpacity(ResolvedBackgroundColor); + } + else if (UBorder* BackgroundBorder = Cast(Background.Get())) + { + BackgroundBorder->SetBrushColor(ResolvedBackgroundColor); + } + } +} + +void UInteractionOptionEntryWidget::ApplyHighlightVisuals() +{ + // Alpha 0 (the struct default) means the option didn't request an override - fall back + // to this widget's own Style colors. + const FLinearColor ResolvedHighlightColor = Option.HighlightColorOverride.A > 0.0f ? Option.HighlightColorOverride : HighlightColor; + const FLinearColor ResolvedTextColor = Option.TextColorOverride.A > 0.0f + ? Option.TextColorOverride + : (bHighlighted ? HighlightedTextColor : NormalTextColor); + + if (Highlight) + { + Highlight->SetVisibility(bHighlighted ? ESlateVisibility::HitTestInvisible : ESlateVisibility::Hidden); + + if (UImage* HighlightImage = Cast(Highlight.Get())) + { + HighlightImage->SetColorAndOpacity(ResolvedHighlightColor); + } + else if (UBorder* HighlightBorder = Cast(Highlight.Get())) + { + HighlightBorder->SetBrushColor(ResolvedHighlightColor); + } + } + + if (InteractionText) + { + InteractionText->SetColorAndOpacity(ResolvedTextColor); + } +} diff --git a/Source/KingshearthLegacy/UI/InteractionOptionEntryWidget.h b/Source/KingshearthLegacy/UI/InteractionOptionEntryWidget.h new file mode 100644 index 0000000..42c2d0b --- /dev/null +++ b/Source/KingshearthLegacy/UI/InteractionOptionEntryWidget.h @@ -0,0 +1,103 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "Blueprint/IUserObjectListEntry.h" +#include "Interaction/InteractionTypes.h" +#include "InteractionOptionEntryWidget.generated.h" + +class UImage; +class UTextBlock; +class UWidget; + +/** + * One row of the Context Menu's OptionsList (a UListView). Assign a WBP + * subclass of this as that ListView's Entry Widget Class in the Designer - + * name your widgets exactly "InteractionText" (Text), "Icon" (Image) and + * "Highlight" (a Border, or an Image - either works, tinted with + * HighlightColor) and all population/highlight styling is handled here in + * C++, driven by the ListView itself via IUserObjectListEntry; no BP event + * graph logic needed. + */ +UCLASS(abstract) +class KINGSHEARTHLEGACY_API UInteractionOptionEntryWidget : public UUserWidget, public IUserObjectListEntry +{ + GENERATED_BODY() + +public: + + UFUNCTION(BlueprintPure, Category = "Interaction") + const FInteractionOption& GetOption() const { return Option; } + + UFUNCTION(BlueprintPure, Category = "Interaction") + bool IsHighlighted() const { return bHighlighted; } + +protected: + + virtual void NativeConstruct() override; + + /** IUserObjectListEntry - called by the owning ListView when this row is assigned an UInteractionOptionListItem. */ + virtual void NativeOnListItemObjectSet(UObject* ListItemObject) override; + + /** IUserObjectListEntry - called by the owning ListView when this row's selected state changes (drives the Context Menu highlight). */ + virtual void NativeOnItemSelectionChanged(bool bIsSelected) override; + + void ApplyOptionVisuals(); + + /** Applies bHighlighted to Highlight's visibility/tint and InteractionText's color. Called on construct (so freshly-spawned rows never default to "selected") and on every selection change. */ + void ApplyHighlightVisuals(); + + /** Row label - bind a TextBlock named exactly "InteractionText" in the Designer. */ + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr InteractionText; + + /** Option icon - bind an Image named exactly "Icon" in the Designer. Hidden automatically when the option has no Icon set. */ + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr Icon; + + /** Highlight background - bind a Border or an Image named exactly "Highlight" in the Designer. Shown and tinted HighlightColor only while this row is selected/highlighted. */ + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr Highlight; + + /** + * Optional persistent row background - bind a Border or an Image named exactly + * "Background" in the Designer. Unlike Highlight, this isn't gated on selection - it's + * tinted with the option's HighlightColorOverride (from FInteractionOption) whenever one + * is set, so a "can't do this" or "special" row stays visually marked regardless of + * whether it's currently highlighted. Left at its Designer-authored appearance when the + * option has no override. + */ + UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional = true)) + TObjectPtr Background; + + /** Tint applied to Highlight while this row is highlighted. */ + UPROPERTY(EditAnywhere, Category = "Interaction|Style") + FLinearColor HighlightColor = FLinearColor(1.0f, 0.85f, 0.3f, 1.0f); + + /** InteractionText color while this row is NOT highlighted. */ + UPROPERTY(EditAnywhere, Category = "Interaction|Style") + FLinearColor NormalTextColor = FLinearColor::White; + + /** InteractionText color while this row IS highlighted. */ + UPROPERTY(EditAnywhere, Category = "Interaction|Style") + FLinearColor HighlightedTextColor = FLinearColor::Black; + + UPROPERTY(BlueprintReadOnly, Category = "Interaction") + FInteractionOption Option; + + UPROPERTY(BlueprintReadOnly, Category = "Interaction") + bool bHighlighted = false; + +private: + + /** + * Background's Designer-authored color, captured once in NativeConstruct before any + * option is ever assigned. Rows are recycled by the ListView, so a row previously tinted + * by one option's HighlightColorOverride needs an explicit color to fall back to when + * reassigned to a different, unconfigured option - rather than just leaving whatever + * tint the recycled widget last had. + */ + FLinearColor DefaultBackgroundColor = FLinearColor::White; +}; diff --git a/Source/KingshearthLegacy/UI/InteractionOptionListItem.h b/Source/KingshearthLegacy/UI/InteractionOptionListItem.h new file mode 100644 index 0000000..fd1bb85 --- /dev/null +++ b/Source/KingshearthLegacy/UI/InteractionOptionListItem.h @@ -0,0 +1,24 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Object.h" +#include "Interaction/InteractionTypes.h" +#include "InteractionOptionListItem.generated.h" + +/** + * Plain UObject wrapper around one FInteractionOption - UListView items must be + * UObjects, not structs, so UInteractionContextMenuWidget wraps each option in + * one of these before calling OptionsList->SetListItems. + */ +UCLASS() +class KINGSHEARTHLEGACY_API UInteractionOptionListItem : public UObject +{ + GENERATED_BODY() + +public: + + UPROPERTY(BlueprintReadOnly, Category = "Interaction") + FInteractionOption Option; +}; diff --git a/Source/KingshearthLegacy/UI/InteractionPromptWidget.cpp b/Source/KingshearthLegacy/UI/InteractionPromptWidget.cpp new file mode 100644 index 0000000..564cf2e --- /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 += FString::Printf(TEXT("\nHold E for %s"), *LastFocusedOptions[1].DisplayName.ToString()); + } + 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; +}; 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; };