Files
inventory-system-plugin/Source/ItemInteraction/Private/ItemInteractionComponent.cpp
Bonchellon 7f7e043a88 Initial commit: Item Interaction Ecosystem plugin (UE5.7)
Server-authoritative, data-driven item/inventory/interaction/carry/equipment/
world-slot/placement/crafting framework. 8 modules, compiles against UE 5.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:49:56 +03:00

521 lines
15 KiB
C++

// 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<FLifetimeProperty>& 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<APawn>(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<FInteractionOption> 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<UWorldItemProvider>())
{
return false;
}
IWorldItemProvider* Provider = Cast<IWorldItemProvider>(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<UInventoryComponent>() : 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<FInventoryContainer> 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<UInventoryComponent>() : 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<UInventoryComponent>() : nullptr)
{
Inv->SplitStack(InstanceId, Amount);
}
}
void UItemInteractionComponent::Server_MergeStack_Implementation(FGuid SourceInstanceId, FGuid TargetInstanceId)
{
APawn* Pawn = GetPawn();
if (UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass<UInventoryComponent>() : 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<UInventoryComponent>() : 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);
}