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>
521 lines
15 KiB
C++
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);
|
|
}
|