diff --git a/Content/GameData/Items/Seeds/I_Lavender_Seed.uasset b/Content/GameData/Items/Seeds/I_Lavender_Seed.uasset index 1508d172..580da41d 100644 --- a/Content/GameData/Items/Seeds/I_Lavender_Seed.uasset +++ b/Content/GameData/Items/Seeds/I_Lavender_Seed.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7705da197aa7a68fb4d2823da721300e463b438374cffad24f6b8a331254289e -size 7259 +oid sha256:e1dfb4fc25f1a6601c4a75f5732867bf0bcaf62436e66632ddd5e08df898bc86 +size 6993 diff --git a/Content/Input/IA_InteractHold.uasset b/Content/Input/IA_InteractHold.uasset new file mode 100644 index 00000000..cc2c2d04 --- /dev/null +++ b/Content/Input/IA_InteractHold.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb77abe3c1a016e64a3ba553fc89f69073093ef5917a3d31a1ff9c09c3fc191b +size 1564 diff --git a/Content/Input/IMC_ActionContext.uasset b/Content/Input/IMC_ActionContext.uasset index 3107b03e..6e579281 100644 --- a/Content/Input/IMC_ActionContext.uasset +++ b/Content/Input/IMC_ActionContext.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:147694e4db54924d7892b8df40d2eb2f417c4ed038f72da7a0c41ad42b8d0ede -size 6253 +oid sha256:cd71d78319a48d1c5f6c573fd56d4ff94d7332c40a273403545456453e6bd025 +size 7007 diff --git a/Content/ThirdPersonBP/Blueprints/MainPlayerController.uasset b/Content/ThirdPersonBP/Blueprints/MainPlayerController.uasset index 5d066bfd..a59e0303 100644 --- a/Content/ThirdPersonBP/Blueprints/MainPlayerController.uasset +++ b/Content/ThirdPersonBP/Blueprints/MainPlayerController.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b7bf922683f8c4a5a1f642583653a0b3e5292dfdd2842a19d417b9a78e470f75 -size 74953 +oid sha256:de291185a67851c7717c41f6536b2efc08af08bc4f61c6006a5c9d275d5a43dc +size 75149 diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Private/Subsystem/InteractionSubsystem.cpp b/Plugins/InteractionSystem/Source/InteractionSystem/Private/Subsystem/InteractionSubsystem.cpp index c801d81a..3fb4b20d 100644 --- a/Plugins/InteractionSystem/Source/InteractionSystem/Private/Subsystem/InteractionSubsystem.cpp +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Private/Subsystem/InteractionSubsystem.cpp @@ -84,14 +84,24 @@ AActor* UInteractionSubsystem::GetClosestInteractable(const UObject* WorldContex if (!Actor.IsValid()) continue; - const float Dist = FVector::DistSquared(Actor->GetActorLocation(), PlayerCharacter->GetActorLocation()); + // Actors whose real interaction target is not their own origin (a bloomed plant inside a seed bed + // grid, for example) report the point everything should be measured against. Distance, the on-screen + // test and the "closest wins" pick then all reason about that point instead of the actor's center. + // Zero means the actor has no preference, so its own origin is used. + FVector FocusLocation = IInteractableActorInterface::Execute_GetInteractionFocusLocation(Actor.Get()); + if (FocusLocation.IsZero()) + { + FocusLocation = Actor->GetActorLocation(); + } + + const float Dist = FVector::DistSquared(FocusLocation, PlayerCharacter->GetActorLocation()); const float MaxInteractionDistance = IInteractableActorInterface::Execute_GetInteractionDistance(Actor.Get()); if (FMath::Pow(MaxInteractionDistance, 2) < Dist || Dist > ClosestDist) continue; FVector2D ScreenLoc; - if (!PC->ProjectWorldLocationToScreen(Actor->GetActorLocation(), ScreenLoc)) + if (!PC->ProjectWorldLocationToScreen(FocusLocation, ScreenLoc)) { continue; } diff --git a/Plugins/InteractionSystem/Source/InteractionSystem/Public/Interface/InteractableActorInterface.h b/Plugins/InteractionSystem/Source/InteractionSystem/Public/Interface/InteractableActorInterface.h index f66be44c..1f8aafbb 100644 --- a/Plugins/InteractionSystem/Source/InteractionSystem/Public/Interface/InteractableActorInterface.h +++ b/Plugins/InteractionSystem/Source/InteractionSystem/Public/Interface/InteractableActorInterface.h @@ -3,6 +3,7 @@ #pragma once #include "CoreMinimal.h" +#include "GameFramework/Actor.h" #include "UObject/Interface.h" #include "InteractionSettings.h" #include "InteractableActorInterface.generated.h" @@ -44,4 +45,18 @@ public: { return GetDefault()->InteractableMaxDistance; } + + /** + * Point the interaction system should reason about for this actor: the distance check, the on-screen + * test and the "closest wins" pick all measure towards it. Zero means "no preference" and the actor's + * own origin is used, which is right for a single-object actor. An actor whose real interaction target + * sits somewhere else (a bloomed plant inside a seed bed grid, for example) overrides this so the prompt + * lands where the player looks. + */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interactable") + FVector GetInteractionFocusLocation() const; + virtual FVector GetInteractionFocusLocation_Implementation() const + { + return FVector::ZeroVector; + } }; diff --git a/Source/ProjectEleri/Items/SeedBedActor.cpp b/Source/ProjectEleri/Items/SeedBedActor.cpp index 0990dbdb..0829ba79 100644 --- a/Source/ProjectEleri/Items/SeedBedActor.cpp +++ b/Source/ProjectEleri/Items/SeedBedActor.cpp @@ -19,6 +19,8 @@ #include "Kismet/KismetMaterialLibrary.h" #include "Materials/MaterialInstanceDynamic.h" #include "Materials/MaterialInterface.h" +#include "GameFramework/Pawn.h" +#include "GameFramework/PlayerController.h" #include "ProjectEleri/Components/EleriPaperSpriteComponent.h" #include "ProjectEleri/Settings/EleriGameSettings.h" #include "NiagaraComponent.h" @@ -134,6 +136,10 @@ ASeedBedActor::ASeedBedActor() USceneComponent* Root = CreateDefaultSubobject(TEXT("Root")); Root->SetMobility(EComponentMobility::Movable); SetRootComponent(Root); + + // The prompt is attached to the bed so a slot location can be expressed relative to it and follows it + WidgetComponent = CreateDefaultSubobject(TEXT("WidgetComponent")); + WidgetComponent->SetupAttachment(Root); } void ASeedBedActor::BeginPlay() @@ -240,7 +246,165 @@ void ASeedBedActor::EndPlay(const EEndPlayReason::Type EndPlayReason) void ASeedBedActor::Tick(float DeltaTime) { Super::Tick(DeltaTime); + + // The interaction plugin picks its target every tick, so the slot the prompt points at has to be fresh too + UpdateBloomedSlotTarget(); + + // Keep the prompt glued to the bloomed plant while the player walks around the bed + if (bInteractionUIActive && bHasBloomedSlotInRange) + { + PlaceInteractionWidgetOverSlot(ClosestBloomedSlot); + } } + +//------------------------------------------------------------------------------------------------ +// INTERACTION +//------------------------------------------------------------------------------------------------ + +void ASeedBedActor::UpdateBloomedSlotTarget() +{ + bHasBloomedSlotInRange = false; + ClosestBloomedSlot = FIntVector2::NoneValue; + + const UWorld* World = GetWorld(); + const APlayerController* PlayerController = World ? World->GetFirstPlayerController() : nullptr; + const APawn* PlayerPawn = PlayerController ? PlayerController->GetPawn() : nullptr; + if (!IsValid(PlayerPawn)) + { + return; + } + + FIntVector2 FoundSlot; + if (FindClosestBloomedSlot(PlayerPawn->GetActorLocation(), FoundSlot)) + { + ClosestBloomedSlot = FoundSlot; + bHasBloomedSlotInRange = true; + } +} + +bool ASeedBedActor::FindClosestBloomedSlot(const FVector& FromWorldLocation, FIntVector2& OutSlotCoordinate) const +{ + // The reach is measured from the slot and not from the center of the bed, so an outer plant is as easy + // to reach as a middle one even though the actor origin is far away from it + const float ReachSquared = FMath::Square(BloomInteractDistance); + + float ClosestDistanceSquared = ReachSquared; + bool bFound = false; + + for (const TPair& SlotPair : SeedBedSeedSlotsMap) + { + // Only a living plant that has reached its bloom can be collected off the bed + if (!SlotHasLivingPlant(SlotPair.Value) || !SlotPair.Value.SlotData.bBloomed) + { + continue; + } + + FVector SlotWorldLocation; + if (!GetSlotWorldLocation(SlotPair.Key, SlotWorldLocation)) + { + continue; + } + + const float DistanceSquared = FVector::DistSquared(FromWorldLocation, SlotWorldLocation); + if (DistanceSquared > ClosestDistanceSquared) + { + continue; + } + + ClosestDistanceSquared = DistanceSquared; + OutSlotCoordinate = SlotPair.Key; + bFound = true; + } + + return bFound; +} + +void ASeedBedActor::PlaceInteractionWidgetOverSlot(FIntVector2 SlotCoordinate) +{ + if (!WidgetComponent) + { + return; + } + + // The widget is attached to the root, so a slot location is expressed relative to the bed and follows it + WidgetComponent->SetRelativeLocation(GetSlotRelativeLocation(SlotCoordinate) + FVector(0.f, 0.f, InteractionPromptHeight)); +} + +bool ASeedBedActor::CollectBloomedSlot(FIntVector2 SlotCoordinate) +{ + FSeedBedSeedSlot* Slot = SeedBedSeedSlotsMap.Find(SlotCoordinate); + if (!Slot || !SlotHasLivingPlant(*Slot) || !Slot->SlotData.bBloomed) + { + return false; + } + + // Harvesting empties the slot and takes the plant mesh with it, so the next seed can be planted right away + ClearSeedFromSlot(SlotCoordinate, true); + + // The collected plant was the prompt's target, the next tick re-evaluates the bed + ClosestBloomedSlot = FIntVector2::NoneValue; + bHasBloomedSlotInRange = false; + + return true; +} + +bool ASeedBedActor::IsInteractable_Implementation() const +{ + return bHasBloomedSlotInRange; +} + +FText ASeedBedActor::GetInteractionText_Implementation() const +{ + return BloomedPlantInteractionText; +} + +void ASeedBedActor::Interact_Implementation(AActor* InteractionInstigator) +{ + if (ClosestBloomedSlot != FIntVector2::NoneValue) + { + CollectBloomedSlot(ClosestBloomedSlot); + } +} + +void ASeedBedActor::ToggleInteractableUI_Implementation(bool bActive) +{ + if (bActive && !IInteractableActorInterface::Execute_IsInteractable(this)) + { + return; + } + + IInteractableActorInterface::ToggleInteractableUI_Implementation(bActive); + + bInteractionUIActive = bActive; + + // Move the prompt over the bloomed plant before it is shown, so it never flashes at the actor origin + if (bActive) + { + PlaceInteractionWidgetOverSlot(ClosestBloomedSlot); + } + + if (WidgetComponent) + { + WidgetComponent->ToggleWidget(bActive); + } +} + +float ASeedBedActor::GetInteractionDistance_Implementation() const +{ + return BloomInteractDistance; +} + +FVector ASeedBedActor::GetInteractionFocusLocation_Implementation() const +{ + FVector SlotWorldLocation; + if (ClosestBloomedSlot != FIntVector2::NoneValue && GetSlotWorldLocation(ClosestBloomedSlot, SlotWorldLocation)) + { + return SlotWorldLocation; + } + + return GetActorLocation(); +} + //------------------------------------------------------------------------------------------------ // TIME //------------------------------------------------------------------------------------------------ @@ -677,6 +841,13 @@ void ASeedBedActor::ApplySlotVisuals(FIntVector2 SlotCoordinate, FSeedBedSeedSlo Slot.SeedBedDynamicMat->SetScalarParameterValue(TEXT("Wetness"), Slot.SlotData.MoistureHoursRemaining > 0 ? 1.f : 0.f); } } + + // VFX + if (IsValid(Slot.NiagaraComponent) && !Slot.SlotData.bBloomed && Slot.NiagaraComponent->IsVisible()) + { + Slot.NiagaraComponent->Deactivate(); + Slot.NiagaraComponent->SetVisibility(false); + } // PLANT ---------------------------------------------------------------------------------------- if (!IsValid(Slot.PlantSprite)) diff --git a/Source/ProjectEleri/Items/SeedBedActor.h b/Source/ProjectEleri/Items/SeedBedActor.h index 4325b83c..c89c1251 100644 --- a/Source/ProjectEleri/Items/SeedBedActor.h +++ b/Source/ProjectEleri/Items/SeedBedActor.h @@ -4,6 +4,8 @@ #include "CoreMinimal.h" #include "GameFramework/Actor.h" +#include "Components/InteractionWidgetComponent.h" +#include "Interface/InteractableActorInterface.h" #include "ProjectEleri/System/TimeOfDayStruct.h" #include "SeedBedActor.generated.h" @@ -114,7 +116,7 @@ struct FSeedBedSaveData }; UCLASS() -class PROJECTELERI_API ASeedBedActor : public AActor +class PROJECTELERI_API ASeedBedActor : public AActor, public IInteractableActorInterface { GENERATED_BODY() @@ -122,6 +124,32 @@ public: ASeedBedActor(); virtual void Tick(float DeltaTime) override; + //------------------------------------------------------------------------------------------------ + // INTERACTION + //------------------------------------------------------------------------------------------------ + + /** True while a living, bloomed plant is close enough to the player to be collected */ + virtual bool IsInteractable_Implementation() const override; + + /** Text shown on the prompt floating above a bloomed plant */ + virtual FText GetInteractionText_Implementation() const override; + + /** Harvests the bloomed plant the prompt currently points at */ + virtual void Interact_Implementation(AActor* InteractionInstigator) override; + + /** Shows / hides the interaction prompt and moves it over the bloomed plant */ + virtual void ToggleInteractableUI_Implementation(bool bActive) override; + + virtual float GetInteractionDistance_Implementation() const override; + + /** The plugin targets this point instead of the actor origin, so range and on-screen checks are measured + * to the bloomed plant rather than to the center of the bed */ + virtual FVector GetInteractionFocusLocation_Implementation() const override; + + /** Collects the bloomed plant of a slot, returns false when the slot holds nothing harvestable */ + UFUNCTION(BlueprintCallable, Category = "Seed Bed|Interaction") + bool CollectBloomedSlot(FIntVector2 SlotCoordinate); + //------------------------------------------------------------------------------------------------ // DATA //------------------------------------------------------------------------------------------------ @@ -319,6 +347,45 @@ protected: UFUNCTION() void HandleHourPassed(); + //------------------------------------------------------------------------------------------------ + // INTERACTION + //------------------------------------------------------------------------------------------------ + + /** Re-evaluates which bloomed slot (if any) is within reach of the local player */ + void UpdateBloomedSlotTarget(); + + /** Finds the living, bloomed slot closest to the given world location and within BloomInteractDistance */ + bool FindClosestBloomedSlot(const FVector& FromWorldLocation, FIntVector2& OutSlotCoordinate) const; + + /** Moves the interaction widget above the given slot */ + void PlaceInteractionWidgetOverSlot(FIntVector2 SlotCoordinate); + + /** Prompt floating above a bloomed plant, created once and moved to whichever slot is in reach */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seed Bed|Interaction") + UInteractionWidgetComponent* WidgetComponent = nullptr; + + /** How close the player has to be to a bloomed plant for it to become interactable */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Seed Bed|Interaction") + float BloomInteractDistance = 250.f; + + /** How far above a slot the interaction prompt floats */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Seed Bed|Interaction") + float InteractionPromptHeight = 120.f; + + /** Text shown on the prompt floating above a bloomed plant */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Seed Bed|Interaction") + FText BloomedPlantInteractionText = NSLOCTEXT("SeedBedInteraction", "CollectBloomedPlant", "Collect"); + + /** Slot the interaction prompt currently points at, FIntVector2::NoneValue when there is none */ + UPROPERTY(Transient) + FIntVector2 ClosestBloomedSlot = FIntVector2::NoneValue; + + /** True while a living, bloomed plant is within BloomInteractDistance of the local player */ + bool bHasBloomedSlotInRange = false; + + /** True while the interaction plugin is showing this bed's prompt widget */ + bool bInteractionUIActive = false; + /** Whether the highlight decals of the bed are currently shown */ bool bPlantingHighlightActive = false; diff --git a/Source/ProjectEleri/Private/EleriPlayerController.cpp b/Source/ProjectEleri/Private/EleriPlayerController.cpp index 338a9461..67ca6de4 100644 --- a/Source/ProjectEleri/Private/EleriPlayerController.cpp +++ b/Source/ProjectEleri/Private/EleriPlayerController.cpp @@ -210,6 +210,11 @@ void AEleriPlayerController::SetupInputComponent() { PlayerEnhancedInputComponent->BindAction(InteractAction, ETriggerEvent::Started, this, &AEleriPlayerController::OnInteract); } + if (InteractHoldAction) + { + PlayerEnhancedInputComponent->BindAction(InteractHoldAction, ETriggerEvent::Started, this, &AEleriPlayerController::OnInteractHoldStart); + PlayerEnhancedInputComponent->BindAction(InteractHoldAction, ETriggerEvent::Triggered, this, &AEleriPlayerController::OnInteractHold); + } if (InteractExitAction) { PlayerEnhancedInputComponent->BindAction(InteractExitAction, ETriggerEvent::Started, this, &AEleriPlayerController::OnInteractExit); @@ -1177,6 +1182,20 @@ void AEleriPlayerController::OnInteract(const FInputActionValue &Value) PlayerCharacter->OnInteract.Broadcast(); } +void AEleriPlayerController::OnInteractHoldStart(const FInputActionValue& Value) +{ + OnButtonHeld.Broadcast(true, InteractHoldAction); +} + +void AEleriPlayerController::OnInteractHold(const FInputActionValue& Value) +{ + if (!IsValid(PlayerCharacter)) + return; + + UE_LOG(LogTemp, Warning, TEXT("INTERACT HOLD")); + OnButtonHeld.Broadcast(false, InteractHoldAction); +} + void AEleriPlayerController::OnInteractExit(const FInputActionValue &Value) { if (!IsValid(PlayerCharacter)) diff --git a/Source/ProjectEleri/Public/EleriPlayerController.h b/Source/ProjectEleri/Public/EleriPlayerController.h index 3f73eadd..61bc0f6a 100644 --- a/Source/ProjectEleri/Public/EleriPlayerController.h +++ b/Source/ProjectEleri/Public/EleriPlayerController.h @@ -46,6 +46,7 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnWatering, const bool, Started); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnChangeInputContext, const int32, Index); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnRemovalModeToggle, const bool, Active); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnPlayerToggleFlying, const bool, Active); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnButtonHeld, const bool, StartHolding, const UInputAction*, InputAction); DECLARE_DELEGATE_OneParam(FOnMinigameComplete, int32 /*Score*/); @@ -129,6 +130,9 @@ public: UPROPERTY(BlueprintAssignable) FOnPlayerToggleFlying OnPlayerToggleFlying; + + UPROPERTY(BlueprintAssignable) + FOnButtonHeld OnButtonHeld; UPROPERTY(BlueprintReadOnly) AMyCharacter* PlayerCharacter; @@ -224,6 +228,8 @@ public: UPROPERTY(EditDefaultsOnly, Category = "Action Input") UInputAction* InteractAction; UPROPERTY(EditDefaultsOnly, Category = "Action Input") + UInputAction* InteractHoldAction; + UPROPERTY(EditDefaultsOnly, Category = "Action Input") UInputAction* InteractExitAction; UPROPERTY(EditDefaultsOnly, Category = "Action Input") @@ -372,6 +378,8 @@ public: void ToggleBazarInput(bool bActive); void OnInteract(const FInputActionValue& Value); + void OnInteractHoldStart(const FInputActionValue& Value); + void OnInteractHold(const FInputActionValue& Value); void OnInteractExit(const FInputActionValue& Value); void OnHarvestSeed(const FInputActionValue& Value);