// Copyright ExByte Studios. Item Interaction Ecosystem. #include "ItemInteractionComponent.h" #include "InteractionResolver.h" #include "InteractableComponent.h" #include "InteractionAction.h" #include "ItemInteractionInterfaces.h" #include "ItemInteractionLog.h" #include "ItemNativeTags.h" #include "InventoryComponent.h" #include "ItemTransaction.h" #include "GameFramework/Pawn.h" #include "Engine/World.h" #include "Net/UnrealNetwork.h" UItemInteractionComponent::UItemInteractionComponent() { PrimaryComponentTick.bCanEverTick = true; PrimaryComponentTick.bStartWithTickEnabled = false; SetIsReplicatedByDefault(true); } void UItemInteractionComponent::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME_CONDITION(UItemInteractionComponent, ActiveAction, COND_OwnerOnly); } void UItemInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); if (!bHasPending || !GetOwner() || !GetOwner()->HasAuthority()) { return; } // Interrupt if the player walked away from the target (section 19). if (PendingRequest.TargetActor && PendingMaxDistanceSq > 0.f) { const float DistSq = FVector::DistSquared(GetOwner()->GetActorLocation(), PendingRequest.TargetActor->GetActorLocation()); if (DistSq > PendingMaxDistanceSq) { AbortTimedAction(); return; } } if (GetWorld()->GetTimeSeconds() >= PendingEndTime) { CompleteTimedAction(); } } APawn* UItemInteractionComponent::GetPawn() const { return Cast(GetOwner()); } void UItemInteractionComponent::RequestInteraction(const FInteractionOption& Option) { // Opening a container is a purely local UI action: the shared container already // replicates to this client, so no server round-trip is needed (section 23). if (Option.Mode == EItemInteractionMode::OpenLootUI) { OnOpenContainer.Broadcast(Option.Request.TargetActor); return; } FInteractionRequest Request = Option.Request; if (!Request.RequestId.IsValid()) { Request.RequestId = FGuid::NewGuid(); } SendRequest(Request); } void UItemInteractionComponent::SendRequest(const FInteractionRequest& Request) { Server_RequestInteraction(Request); } void UItemInteractionComponent::Server_RequestInteraction_Implementation(const FInteractionRequest& Request) { FInteractionContext Context; FInteractionOption Authorized; if (!AuthorizeRequest(Request, Context, Authorized)) { Client_InteractionResult(Request.RequestId, false); return; } // Timed / hold actions run over time with interruption (section 19); instant ones execute now. const bool bTimed = Authorized.ActionDuration > 0.f || Authorized.bRequiresHold; if (bTimed && !bHasPending) { BeginTimedAction(Context, Authorized, Request); return; } const bool bSuccess = ExecuteAuthorized(Context, Authorized, Request); Client_InteractionResult(Request.RequestId, bSuccess); } bool UItemInteractionComponent::AuthorizeRequest(const FInteractionRequest& Request, FInteractionContext& OutContext, FInteractionOption& OutOption) const { APawn* Pawn = GetPawn(); AActor* Target = Request.TargetActor; if (!Pawn || !Target) { UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: missing pawn/target."), *Request.ActionTag.ToString()); return false; } // Distance + line of sight gate (section 9.3-9.4). OutContext = UInteractionResolver::BuildContext(Pawn, Target); if (UInteractableComponent* Interactable = OutContext.TargetInteractable) { const float MaxDist = Interactable->InteractionDistance + ServerDistanceTolerance; if (FVector::DistSquared(Pawn->GetActorLocation(), Target->GetActorLocation()) > FMath::Square(MaxDist)) { UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: out of range."), *Request.ActionTag.ToString()); return false; } if (Interactable->bIsBusy) { UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: target busy."), *Request.ActionTag.ToString()); return false; } } // Re-resolve and require the requested option to actually be available + enabled (section 9.8-9.9). TArray Options; UInteractionResolver::GetAvailableInteractions(Pawn, Target, Options); for (const FInteractionOption& Opt : Options) { if (Opt.ActionTag == Request.ActionTag && Opt.Mode == Request.Mode) { if (!Opt.bIsEnabled) { UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: %s"), *Request.ActionTag.ToString(), *Opt.DisabledReason.ToString()); return false; } OutOption = Opt; return true; } } UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: option not available."), *Request.ActionTag.ToString()); return false; } bool UItemInteractionComponent::ServerExecuteMode(const FInteractionContext& Context, EItemInteractionMode Mode, FGameplayTag ActionTag) { if (!GetOwner() || !GetOwner()->HasAuthority()) { return false; } FInteractionOption Option; Option.Mode = Mode; Option.ActionTag = ActionTag; FInteractionRequest Request; Request.Mode = Mode; Request.ActionTag = ActionTag; Request.TargetActor = Context.TargetActor; if (Context.bHasHeldItem) { Request.ItemInstanceId = Context.HeldItem.InstanceId; } else if (Context.bHasTargetItem) { Request.ItemInstanceId = Context.TargetItem.InstanceId; } return ExecuteAuthorized(Context, Option, Request); } bool UItemInteractionComponent::ExecuteAuthorized(const FInteractionContext& Context, const FInteractionOption& Option, const FInteractionRequest& Request) { switch (Option.Mode) { case EItemInteractionMode::InstantPickup: case EItemInteractionMode::PickupToInventory: return ExecutePickup(Context); case EItemInteractionMode::TransferContainerItem: return ExecuteTransfer(Context, Request); default: // Anything else routes to a matching action-class on the interactable. // Built-in carry/equip/install/placement modes are implemented in their // respective stages (held/equipment/world-slot/placement modules). if (ExecuteActionClass(Context, Option)) { return true; } UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] Mode %d has no handler yet."), (int32)Option.Mode); return false; } } bool UItemInteractionComponent::ExecutePickup(const FInteractionContext& Context) { if (!Context.TargetActor || !Context.PlayerInventory || !Context.TargetActor->Implements()) { return false; } IWorldItemProvider* Provider = Cast(Context.TargetActor); if (!Provider || !Provider->IsItemAvailable()) { return false; } FItemInstanceData Item; if (!Provider->GetProvidedItem(Item) || !Item.IsValid()) { return false; } const int32 Before = Item.Quantity; Context.PlayerInventory->AddItem(Item); // Item.Quantity now holds the leftover const int32 Added = Before - Item.Quantity; if (Added <= 0) { return false; } UE_LOG(LogItemInteraction, Log, TEXT("[Interaction] %s picked up %s x%d"), *GetNameSafe(GetOwner()), *Item.ItemId.ToString(), Added); if (Item.Quantity <= 0) { Provider->NotifyItemTaken(GetOwner()); } else { Provider->UpdateProvidedItem(Item); } return true; } bool UItemInteractionComponent::ExecuteActionClass(const FInteractionContext& Context, const FInteractionOption& Option) { if (!Context.TargetInteractable) { return false; } for (UInteractionAction* Action : Context.TargetInteractable->AvailableActions) { if (Action && Action->ActionTag == Option.ActionTag) { FText Reason; if (!Action->CanExecute(Context, Reason)) { UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: %s"), *Option.ActionTag.ToString(), *Reason.ToString()); return false; } Action->Execute(Context); return true; } } return false; } // --- Cross-inventory transfer (loot) --- bool UItemInteractionComponent::ExecuteTransfer(const FInteractionContext& Context, const FInteractionRequest& Request) { UInventoryComponent* PlayerInv = Context.PlayerInventory; UInventoryComponent* StorageInv = Context.TargetActor ? Context.TargetActor->FindComponentByClass() : nullptr; if (!PlayerInv) { return false; } // Determine which inventory currently holds the item -> source; the other -> dest. FItemInstanceData Item; UInventoryComponent* Source = nullptr; UInventoryComponent* Dest = nullptr; if (PlayerInv->FindItem(Request.ItemInstanceId, Item)) { Source = PlayerInv; Dest = StorageInv; } else if (StorageInv && StorageInv->FindItem(Request.ItemInstanceId, Item)) { Source = StorageInv; Dest = PlayerInv; } if (!Source || !Dest || !Item.IsValid() || Item.Lock.bLocked) { return false; } // Pick the destination container. FGuid DestContainer = Request.TargetContainerId; if (!DestContainer.IsValid() || !Dest->CanAcceptItem(Item, DestContainer)) { TArray Containers; Dest->GetContainers(Containers); DestContainer = FGuid(); for (const FInventoryContainer& C : Containers) { if (Dest->CanAcceptItem(Item, C.ContainerId)) { DestContainer = C.ContainerId; break; } } } if (!DestContainer.IsValid()) { UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] Transfer failed: no space.")); return false; } // Atomic remove-from-source + add-to-dest (rolls back if either fails). FItemTransaction Tx; Tx.Instigator = GetOwner(); Tx.Remove(Source, Item.InstanceId).Add(Dest, Item); if (!Tx.Apply()) { UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] Transfer transaction failed.")); return false; } UE_LOG(LogItemInteraction, Log, TEXT("[Inventory] transferred %s x%d"), *Item.ItemId.ToString(), Item.Quantity); return true; } // --- Inventory UI operation entry points --- void UItemInteractionComponent::RequestMoveItem(AActor* StorageActor, FGuid InstanceId, FGuid ToContainerId, int32 ToSlot) { Server_MoveItem(StorageActor, InstanceId, ToContainerId, ToSlot); } void UItemInteractionComponent::RequestSplitStack(FGuid InstanceId, int32 Amount) { Server_SplitStack(InstanceId, Amount); } void UItemInteractionComponent::RequestMergeStack(FGuid SourceInstanceId, FGuid TargetInstanceId) { Server_MergeStack(SourceInstanceId, TargetInstanceId); } void UItemInteractionComponent::RequestDropFromInventory(FGuid InstanceId) { Server_DropFromInventory(InstanceId); } void UItemInteractionComponent::Server_MoveItem_Implementation(AActor* StorageActor, FGuid InstanceId, FGuid ToContainerId, int32 ToSlot) { APawn* Pawn = GetPawn(); UInventoryComponent* PlayerInv = Pawn ? Pawn->FindComponentByClass() : nullptr; if (!PlayerInv) { return; } if (StorageActor) { // Cross-inventory transfer; reuse the validated transfer path. FInteractionContext Ctx = UInteractionResolver::BuildContext(Pawn, StorageActor); FInteractionRequest Req; Req.ItemInstanceId = InstanceId; Req.TargetContainerId = ToContainerId; ExecuteTransfer(Ctx, Req); return; } // Rearrange within the player's own inventory. FItemInstanceData Item; if (PlayerInv->FindItem(InstanceId, Item)) { PlayerInv->MoveItem(InstanceId, Item.CurrentContainerId, ToContainerId.IsValid() ? ToContainerId : Item.CurrentContainerId, ToSlot); } } void UItemInteractionComponent::Server_SplitStack_Implementation(FGuid InstanceId, int32 Amount) { APawn* Pawn = GetPawn(); if (UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass() : nullptr) { Inv->SplitStack(InstanceId, Amount); } } void UItemInteractionComponent::Server_MergeStack_Implementation(FGuid SourceInstanceId, FGuid TargetInstanceId) { APawn* Pawn = GetPawn(); if (UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass() : nullptr) { Inv->MergeStack(SourceInstanceId, TargetInstanceId); } } void UItemInteractionComponent::Server_DropFromInventory_Implementation(FGuid InstanceId) { HandleDropFromInventory(InstanceId); } bool UItemInteractionComponent::HandleDropFromInventory(FGuid InstanceId) { // Base cannot spawn a world pickup (that lives in ItemWorld). Overridden there. UE_LOG(LogItemInteraction, Verbose, TEXT("HandleDropFromInventory: no world handler bound (use WorldInteractionComponent).")); return false; } // --- Timed / hold actions (section 19) --- void UItemInteractionComponent::BeginTimedAction(const FInteractionContext& Context, const FInteractionOption& Option, const FInteractionRequest& Request) { bHasPending = true; bPendingHold = Option.bRequiresHold; PendingContext = Context; PendingOption = Option; PendingRequest = Request; const float Duration = Option.ActionDuration > 0.f ? Option.ActionDuration : Option.HoldDuration; PendingEndTime = GetWorld()->GetTimeSeconds() + FMath::Max(0.05f, Duration); // Interrupt radius: the interactable reach + tolerance. float MaxDist = 99999.f; if (Context.TargetInteractable) { MaxDist = Context.TargetInteractable->InteractionDistance + ServerDistanceTolerance; } PendingMaxDistanceSq = FMath::Square(MaxDist); LockActionItem(true); ActiveAction.bActive = true; ActiveAction.ActionTag = Option.ActionTag; ActiveAction.Duration = Duration; OnActionStarted.Broadcast(Option.ActionTag, Duration); // server-side listeners SetComponentTickEnabled(true); } void UItemInteractionComponent::CompleteTimedAction() { const FInteractionOption Option = PendingOption; const FInteractionContext Context = PendingContext; const FInteractionRequest Request = PendingRequest; LockActionItem(false); bHasPending = false; ActiveAction = FActiveInteractionState(); SetComponentTickEnabled(false); const bool bSuccess = ExecuteAuthorized(Context, Option, Request); OnActionFinished.Broadcast(Option.ActionTag, bSuccess); Client_InteractionResult(Request.RequestId, bSuccess); } void UItemInteractionComponent::AbortTimedAction() { if (!bHasPending) { return; } const FInteractionRequest Request = PendingRequest; const FGameplayTag Tag = PendingOption.ActionTag; LockActionItem(false); bHasPending = false; ActiveAction = FActiveInteractionState(); SetComponentTickEnabled(false); OnActionFinished.Broadcast(Tag, false); Client_InteractionResult(Request.RequestId, false); UE_LOG(LogItemInteraction, Verbose, TEXT("[Interaction] action %s interrupted."), *Tag.ToString()); } void UItemInteractionComponent::LockActionItem(bool bLock) { APawn* Pawn = GetPawn(); UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass() : nullptr; if (!Inv || !PendingRequest.ItemInstanceId.IsValid()) { return; } FItemInstanceData Item; if (!Inv->FindItem(PendingRequest.ItemInstanceId, Item)) { return; // item not in player inventory (e.g. held/world) - nothing to lock here } if (bLock) { Inv->SetItemLock(PendingRequest.ItemInstanceId, ItemEcosystemTags::Lock_Using, GetOwner(), 0.f); } else { Inv->ClearItemLock(PendingRequest.ItemInstanceId); } } void UItemInteractionComponent::ReleaseHold() { Server_ReleaseHold(); } void UItemInteractionComponent::Server_ReleaseHold_Implementation() { if (bHasPending && bPendingHold) { AbortTimedAction(); // released before the hold completed } } void UItemInteractionComponent::CancelActiveAction() { if (GetOwner() && GetOwner()->HasAuthority()) { AbortTimedAction(); } } void UItemInteractionComponent::OnRep_ActiveAction() { if (ActiveAction.bActive) { OnActionStarted.Broadcast(ActiveAction.ActionTag, ActiveAction.Duration); } else { OnActionFinished.Broadcast(ActiveAction.ActionTag, true); } } void UItemInteractionComponent::Client_InteractionResult_Implementation(FGuid RequestId, bool bSuccess) { OnInteractionResult.Broadcast(RequestId, bSuccess); }