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>
This commit is contained in:
Bonchellon
2026-06-22 20:49:56 +03:00
commit 7f7e043a88
98 changed files with 9328 additions and 0 deletions

View File

@ -0,0 +1,9 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "InteractableComponent.h"
UInteractableComponent::UInteractableComponent()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}

View File

@ -0,0 +1,162 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "InteractionAction.h"
#include "InteractableComponent.h"
#include "ItemInteractionLog.h"
#include "ItemNativeTags.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemDefinitionRow.h"
#include "InventoryComponent.h"
#include "GameFramework/Character.h"
namespace
{
/** Picks the item a requirement should inspect: held first, else the target item. */
const FItemInstanceData* ResolveSubjectItem(const FInteractionContext& Ctx)
{
if (Ctx.bHasHeldItem) { return &Ctx.HeldItem; }
if (Ctx.bHasTargetItem) { return &Ctx.TargetItem; }
return nullptr;
}
bool ItemHasTag(const UObject* WorldCtx, const FItemInstanceData& Item, const FGameplayTag& Tag)
{
if (const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldCtx))
{
if (const FItemDefinitionRow* Def = DB->GetItemDefinition(Item.ItemId))
{
return Def->ItemTags.HasTag(Tag) || Def->ItemType.MatchesTag(Tag);
}
}
return false;
}
bool EvaluateRequirement(const FInteractionRequirement& Req, const FInteractionContext& Ctx)
{
switch (Req.RequirementType)
{
case EInteractionRequirementType::None:
return true;
case EInteractionRequirementType::HasInventorySpace:
{
if (!Ctx.PlayerInventory) { return false; }
const FItemInstanceData* Subject = Ctx.bHasTargetItem ? &Ctx.TargetItem : ResolveSubjectItem(Ctx);
if (!Subject) { return true; }
TArray<FInventoryContainer> Containers;
Ctx.PlayerInventory->GetContainers(Containers);
for (const FInventoryContainer& C : Containers)
{
if (Ctx.PlayerInventory->CanAcceptItem(*Subject, C.ContainerId))
{
return true;
}
}
return false;
}
case EInteractionRequirementType::HasItemWithTag:
{
if (!Ctx.PlayerInventory) { return false; }
TArray<FItemInstanceData> Items;
Ctx.PlayerInventory->GetAllItems(Items);
for (const FItemInstanceData& It : Items)
{
if (ItemHasTag(Ctx.PlayerInventory.Get(), It, Req.RequiredTag)) { return true; }
}
return false;
}
case EInteractionRequirementType::HasHeldItemWithTag:
return Ctx.bHasHeldItem && ItemHasTag(Ctx.Character.Get(), Ctx.HeldItem, Req.RequiredTag);
case EInteractionRequirementType::PropertyGreaterThan:
{
const FItemInstanceData* Subject = ResolveSubjectItem(Ctx);
return Subject && Subject->RuntimeProperties.GetFloat(Req.RequiredTag) > Req.RequiredValue;
}
case EInteractionRequirementType::PropertyLessThan:
{
const FItemInstanceData* Subject = ResolveSubjectItem(Ctx);
return Subject && Subject->RuntimeProperties.GetFloat(Req.RequiredTag) < Req.RequiredValue;
}
case EInteractionRequirementType::TargetHasFreeSlotTag:
// Re-validated against the world slot component at execution time (stage 8).
return Ctx.TargetActor != nullptr;
case EInteractionRequirementType::NotWet:
{
const FItemInstanceData* Subject = ResolveSubjectItem(Ctx);
return !Subject || Subject->RuntimeProperties.GetFloat(ItemEcosystemTags::Property_Wetness) < 0.5f;
}
case EInteractionRequirementType::NotBroken:
{
const FItemInstanceData* Subject = ResolveSubjectItem(Ctx);
return !Subject || !Subject->StateTags.HasTag(ItemEcosystemTags::State_Broken);
}
default:
return true;
}
}
}
bool UInteractionAction::CanExecute_Implementation(const FInteractionContext& Context, FText& OutDisabledReason) const
{
for (const FInteractionRequirement& Req : Requirements)
{
if (!EvaluateRequirement(Req, Context))
{
OutDisabledReason = Req.FailureText.IsEmpty()
? NSLOCTEXT("ItemInteraction", "RequirementFailed", "Requirement not met")
: Req.FailureText;
return false;
}
}
return true;
}
void UInteractionAction::Execute_Implementation(const FInteractionContext& Context)
{
// Base does nothing; concrete actions / the interaction component handle effects.
UE_LOG(LogItemInteraction, Verbose, TEXT("UInteractionAction '%s' base Execute (no-op)."), *ActionTag.ToString());
}
FInteractionOption UInteractionAction::BuildOption(const FInteractionContext& Context) const
{
FInteractionOption Option;
Option.ActionTag = ActionTag;
Option.Mode = Mode;
Option.DisplayText = DisplayText;
Option.Priority = Priority;
Option.bRequiresHold = bRequiresHold;
Option.HoldDuration = HoldDuration;
Option.ActionDuration = Execution.bHasDuration ? Execution.Duration : 0.f;
FText Reason;
Option.bIsEnabled = CanExecute(Context, Reason);
Option.DisabledReason = Reason;
Option.Request.Mode = Mode;
Option.Request.ActionTag = ActionTag;
Option.Request.TargetActor = Context.TargetActor;
if (Context.bHasTargetItem) { Option.Request.ItemInstanceId = Context.TargetItem.InstanceId; }
else if (Context.bHasHeldItem) { Option.Request.ItemInstanceId = Context.HeldItem.InstanceId; }
return Option;
}
UWorld* UInteractionAction::GetWorld() const
{
if (HasAllFlags(RF_ClassDefaultObject))
{
return nullptr;
}
if (const UObject* Outer = GetOuter())
{
return Outer->GetWorld();
}
return nullptr;
}

View File

@ -0,0 +1,337 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "InteractionResolver.h"
#include "InteractableComponent.h"
#include "InteractionAction.h"
#include "ItemInteractionInterfaces.h"
#include "ItemInteractionLog.h"
#include "ItemNativeTags.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemAuxiliaryRows.h"
#include "InventoryComponent.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/Character.h"
#include "GameFramework/PlayerController.h"
#define LOCTEXT_NAMESPACE "ItemInteraction"
namespace
{
FInteractionOption MakeOption(const FInteractionContext& Ctx, FGameplayTag ActionTag, EItemInteractionMode Mode,
const FText& Text, int32 Priority, bool bRequiresHold = false, float HoldDuration = 0.f, float ActionDuration = 0.f)
{
FInteractionOption Opt;
Opt.ActionTag = ActionTag;
Opt.Mode = Mode;
Opt.DisplayText = Text;
Opt.Priority = Priority;
Opt.bRequiresHold = bRequiresHold;
Opt.HoldDuration = HoldDuration;
Opt.ActionDuration = ActionDuration;
Opt.bIsEnabled = true;
Opt.Request.Mode = Mode;
Opt.Request.ActionTag = ActionTag;
Opt.Request.TargetActor = Ctx.TargetActor;
if (Ctx.bHasHeldItem) { Opt.Request.ItemInstanceId = Ctx.HeldItem.InstanceId; }
if (Ctx.bHasTargetItem) { Opt.Request.ItemInstanceId = Ctx.TargetItem.InstanceId; }
return Opt;
}
/**
* Evaluates a data-driven requirement string like "FuelAmount>0" or
* "BatteryCharge>=0.2" against a subject item's runtime properties (section 18).
* The left side maps to the Property.<Name> tag. Empty string = always true.
*/
bool EvaluateRequirementString(const FItemInstanceData& Subject, const FString& Req)
{
FString Trimmed = Req.TrimStartAndEnd();
if (Trimmed.IsEmpty())
{
return true;
}
// Find the comparison operator (order matters: check 2-char first).
const TCHAR* Ops[] = { TEXT(">="), TEXT("<="), TEXT("=="), TEXT(">"), TEXT("<") };
FString Op, Left, Right;
for (const TCHAR* Candidate : Ops)
{
int32 Idx;
if (Trimmed.FindChar(Candidate[0], Idx))
{
if (Trimmed.Mid(Idx, FCString::Strlen(Candidate)) == Candidate)
{
Op = Candidate;
Left = Trimmed.Left(Idx).TrimStartAndEnd();
Right = Trimmed.Mid(Idx + FCString::Strlen(Candidate)).TrimStartAndEnd();
break;
}
}
}
if (Op.IsEmpty())
{
return true; // unparseable -> don't block
}
const FGameplayTag PropTag = FGameplayTag::RequestGameplayTag(FName(*(FString(TEXT("Property.")) + Left)), false);
const float Value = Subject.RuntimeProperties.GetFloat(PropTag, 0.f);
const float Threshold = FCString::Atof(*Right);
if (Op == TEXT(">")) { return Value > Threshold; }
if (Op == TEXT("<")) { return Value < Threshold; }
if (Op == TEXT(">=")) { return Value >= Threshold; }
if (Op == TEXT("<=")) { return Value <= Threshold; }
if (Op == TEXT("==")) { return FMath::IsNearlyEqual(Value, Threshold); }
return true;
}
bool AnyContainerAccepts(UInventoryComponent* Inv, const FItemInstanceData& Item)
{
if (!Inv) { return false; }
TArray<FInventoryContainer> Containers;
Inv->GetContainers(Containers);
for (const FInventoryContainer& C : Containers)
{
if (Inv->CanAcceptItem(Item, C.ContainerId)) { return true; }
}
return false;
}
}
bool UInteractionResolver::ResolveHeldItem(APawn* Instigator, FItemInstanceData& OutItem)
{
if (!Instigator) { return false; }
auto TryObject = [&OutItem](UObject* Obj) -> bool
{
if (Obj && Obj->Implements<UHeldItemInterface>())
{
IHeldItemInterface* Held = Cast<IHeldItemInterface>(Obj);
if (Held && Held->HasHeldItem())
{
return Held->GetHeldItem(OutItem);
}
}
return false;
};
if (TryObject(Instigator)) { return true; }
for (UActorComponent* Comp : Instigator->GetComponents())
{
if (TryObject(Comp)) { return true; }
}
return false;
}
FInteractionContext UInteractionResolver::BuildContext(APawn* Instigator, AActor* TargetActor)
{
FInteractionContext Ctx;
Ctx.TargetActor = TargetActor;
Ctx.Character = Cast<ACharacter>(Instigator);
if (Instigator)
{
Ctx.PlayerController = Cast<APlayerController>(Instigator->GetController());
Ctx.PlayerInventory = Instigator->FindComponentByClass<UInventoryComponent>();
Ctx.bHasHeldItem = ResolveHeldItem(Instigator, Ctx.HeldItem);
}
if (TargetActor)
{
Ctx.TargetInteractable = TargetActor->FindComponentByClass<UInteractableComponent>();
if (Ctx.TargetInteractable)
{
Ctx.WorldTags = Ctx.TargetInteractable->InteractionTags;
}
if (TargetActor->Implements<UWorldItemProvider>())
{
if (IWorldItemProvider* Provider = Cast<IWorldItemProvider>(TargetActor))
{
Ctx.bHasTargetItem = Provider->GetProvidedItem(Ctx.TargetItem);
}
}
}
return Ctx;
}
void UInteractionResolver::GatherCapabilityOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out)
{
if (!Ctx.bHasTargetItem)
{
return;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(Ctx.TargetActor);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(Ctx.TargetItem.ItemId) : nullptr;
if (!Def)
{
return;
}
const FText Name = Def->DisplayName.IsEmpty() ? FText::FromName(Ctx.TargetItem.ItemId) : Def->DisplayName;
if (Def->bCanInstantPickup || Def->bCanStoreInInventory)
{
const EItemInteractionMode Mode = Def->bCanInstantPickup ? EItemInteractionMode::InstantPickup : EItemInteractionMode::PickupToInventory;
const FGameplayTag Tag = Def->bCanInstantPickup ? ItemEcosystemTags::Action_Pickup_Instant : ItemEcosystemTags::Action_Pickup;
FText Text = Ctx.TargetItem.Quantity > 1
? FText::Format(LOCTEXT("PickUpQty", "Pick up {0} x{1}"), Name, FText::AsNumber(Ctx.TargetItem.Quantity))
: FText::Format(LOCTEXT("PickUp", "Pick up {0}"), Name);
FInteractionOption Opt = MakeOption(Ctx, Tag, Mode, Text, 600);
if (!AnyContainerAccepts(Ctx.PlayerInventory, Ctx.TargetItem))
{
Opt.bIsEnabled = false;
Opt.DisabledReason = LOCTEXT("InventoryFull", "Inventory Full");
}
Out.Add(Opt);
}
if (Def->bCanWorldCarry)
{
FInteractionOption Opt = MakeOption(Ctx, ItemEcosystemTags::Action_Carry, EItemInteractionMode::CarryAttached,
FText::Format(LOCTEXT("Carry", "Carry {0}"), Name), 500);
if (Ctx.bHasHeldItem)
{
Opt.bIsEnabled = false;
Opt.DisabledReason = LOCTEXT("HandsFull", "Hands Full");
}
Out.Add(Opt);
}
}
void UInteractionResolver::GatherContainerOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out)
{
if (!Ctx.TargetActor)
{
return;
}
// A target that owns an inventory (and isn't the player themselves) can be opened.
UInventoryComponent* TargetInv = Ctx.TargetActor->FindComponentByClass<UInventoryComponent>();
if (!TargetInv || TargetInv == Ctx.PlayerInventory)
{
return;
}
FText Label = (Ctx.TargetInteractable && !Ctx.TargetInteractable->DisplayName.IsEmpty())
? FText::Format(LOCTEXT("OpenNamed", "Open {0}"), Ctx.TargetInteractable->DisplayName)
: LOCTEXT("Open", "Open");
Out.Add(MakeOption(Ctx, ItemEcosystemTags::Action_Open, EItemInteractionMode::OpenLootUI, Label, 700));
}
void UInteractionResolver::GatherDataDrivenOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out)
{
const UObject* WorldCtx = Ctx.TargetActor ? static_cast<const UObject*>(Ctx.TargetActor) : static_cast<const UObject*>(Ctx.Character);
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldCtx);
if (!DB)
{
return;
}
// For a "Held+Vehicle" style context, the suffix after '+' must match one of the
// target's world tags or its interactable type (loose, case-insensitive).
auto TargetMatchesSuffix = [&Ctx](const FString& Suffix) -> bool
{
if (Suffix.IsEmpty()) { return true; }
if (Ctx.TargetInteractable && Ctx.TargetInteractable->InteractableType.IsValid()
&& Ctx.TargetInteractable->InteractableType.ToString().Contains(Suffix))
{
return true;
}
for (const FGameplayTag& Tag : Ctx.WorldTags)
{
if (Tag.ToString().Contains(Suffix)) { return true; }
}
return false;
};
auto AppendRows = [&](const FItemInstanceData& Item, bool bHeldContext)
{
TArray<FItemInteractionRow> Rows;
DB->GetInteractionRows(Item.ItemId, Rows);
for (const FItemInteractionRow& Row : Rows)
{
const FString Context = Row.Context.ToString();
const bool bIsHeldRow = Context.StartsWith(TEXT("Held"));
if (bHeldContext != bIsHeldRow)
{
continue;
}
if (bIsHeldRow)
{
FString Suffix;
Context.Split(TEXT("+"), nullptr, &Suffix);
if (!TargetMatchesSuffix(Suffix)) { continue; }
}
FInteractionOption Opt = MakeOption(Ctx, Row.ActionTag, Row.InteractionMode,
FText::FromName(Row.ActionTag.GetTagName()), Row.Priority,
Row.bRequiresHold, Row.HoldDuration, Row.ActionDuration);
Opt.Request.ItemInstanceId = Item.InstanceId;
Opt.Request.Quantity = Item.Quantity;
// Data-driven requirement gate (section 18).
if (!Row.Requirement.IsEmpty() && !EvaluateRequirementString(Item, Row.Requirement))
{
Opt.bIsEnabled = false;
Opt.DisabledReason = FText::FromString(Row.Requirement);
}
Out.Add(Opt);
}
};
if (Ctx.bHasTargetItem) { AppendRows(Ctx.TargetItem, /*bHeldContext*/ false); }
if (Ctx.bHasHeldItem) { AppendRows(Ctx.HeldItem, /*bHeldContext*/ true); }
}
void UInteractionResolver::GatherActionClassOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out)
{
if (!Ctx.TargetInteractable)
{
return;
}
for (const UInteractionAction* Action : Ctx.TargetInteractable->AvailableActions)
{
if (Action)
{
Out.Add(Action->BuildOption(Ctx));
}
}
}
void UInteractionResolver::GetAvailableInteractions(APawn* Instigator, AActor* TargetActor, TArray<FInteractionOption>& OutOptions)
{
if (!TargetActor)
{
return;
}
const FInteractionContext Ctx = BuildContext(Instigator, TargetActor);
GatherActionClassOptions(Ctx, OutOptions);
GatherCapabilityOptions(Ctx, OutOptions);
GatherContainerOptions(Ctx, OutOptions);
GatherDataDrivenOptions(Ctx, OutOptions);
OutOptions.StableSort([](const FInteractionOption& A, const FInteractionOption& B)
{
return A.Priority > B.Priority;
});
}
bool UInteractionResolver::GetPrimaryInteraction(APawn* Instigator, AActor* TargetActor, FInteractionOption& OutOption)
{
TArray<FInteractionOption> Options;
GetAvailableInteractions(Instigator, TargetActor, Options);
for (const FInteractionOption& Opt : Options)
{
if (Opt.bIsEnabled)
{
OutOption = Opt;
return true;
}
}
if (Options.Num() > 0)
{
OutOption = Options[0]; // disabled, but lets UI show the reason
return true;
}
return false;
}
#undef LOCTEXT_NAMESPACE

View File

@ -0,0 +1,86 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "InteractionTraceComponent.h"
#include "InteractableComponent.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
UInteractionTraceComponent::UInteractionTraceComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.TickInterval = 0.1f; // 10 Hz is plenty for prompts
SetIsReplicatedByDefault(false); // purely local
}
void UInteractionTraceComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
TickTrace();
}
bool UInteractionTraceComponent::GetViewPoint(FVector& OutLocation, FRotator& OutRotation) const
{
const APawn* Pawn = Cast<APawn>(GetOwner());
const APlayerController* PC = Pawn ? Cast<APlayerController>(Pawn->GetController()) : nullptr;
if (!PC || !PC->IsLocalController())
{
return false;
}
PC->GetPlayerViewPoint(OutLocation, OutRotation);
return true;
}
void UInteractionTraceComponent::TickTrace()
{
FVector ViewLoc;
FRotator ViewRot;
if (!GetViewPoint(ViewLoc, ViewRot))
{
SetTarget(nullptr, nullptr);
return;
}
const FVector End = ViewLoc + ViewRot.Vector() * TraceDistance;
FCollisionQueryParams Params(SCENE_QUERY_STAT(InteractionTrace), false, GetOwner());
Params.bTraceComplex = false;
FHitResult Hit;
const bool bHit = GetWorld()->SweepSingleByChannel(
Hit, ViewLoc, End, FQuat::Identity, TraceChannel,
FCollisionShape::MakeSphere(TraceRadius), Params);
AActor* HitActor = bHit ? Hit.GetActor() : nullptr;
UInteractableComponent* Interactable = HitActor ? HitActor->FindComponentByClass<UInteractableComponent>() : nullptr;
if (Interactable)
{
// Distance gate against the component's own reach.
const float DistSq = FVector::DistSquared(ViewLoc, Hit.ImpactPoint);
if (DistSq > FMath::Square(Interactable->InteractionDistance))
{
Interactable = nullptr;
HitActor = nullptr;
}
}
else
{
HitActor = nullptr;
}
LastHitLocation = Hit.ImpactPoint;
LastHitNormal = Hit.ImpactNormal;
SetTarget(HitActor, Interactable);
}
void UInteractionTraceComponent::SetTarget(AActor* NewTarget, UInteractableComponent* NewInteractable)
{
if (CurrentTarget == NewTarget && CurrentInteractable == NewInteractable)
{
return;
}
CurrentTarget = NewTarget;
CurrentInteractable = NewInteractable;
OnTargetChanged.Broadcast(NewTarget, NewInteractable);
}

View File

@ -0,0 +1,97 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemInteractionActions.h"
#include "ItemInteractionLog.h"
#include "ItemInteractionInterfaces.h"
#include "ItemNativeTags.h"
#include "InventoryComponent.h"
#include "GameFramework/Character.h"
// --- Instant Pickup ---
UInteractionAction_InstantPickup::UInteractionAction_InstantPickup()
{
ActionTag = ItemEcosystemTags::Action_Pickup_Instant;
Mode = EItemInteractionMode::InstantPickup;
Priority = 600;
DisplayText = NSLOCTEXT("ItemInteraction", "PickUp", "Pick Up");
}
void UInteractionAction_InstantPickup::Execute_Implementation(const FInteractionContext& Context)
{
if (!Context.TargetActor || !Context.PlayerInventory || !Context.TargetActor->Implements<UWorldItemProvider>())
{
return;
}
IWorldItemProvider* Provider = Cast<IWorldItemProvider>(Context.TargetActor);
FItemInstanceData Item;
if (!Provider || !Provider->IsItemAvailable() || !Provider->GetProvidedItem(Item))
{
return;
}
const int32 Before = Item.Quantity;
Context.PlayerInventory->AddItem(Item);
if (Before - Item.Quantity <= 0)
{
return;
}
if (Item.Quantity <= 0) { Provider->NotifyItemTaken(Context.Character.Get()); }
else { Provider->UpdateProvidedItem(Item); }
}
// --- Open Loot UI (client-side; no server effect) ---
UInteractionAction_OpenLootUI::UInteractionAction_OpenLootUI()
{
ActionTag = ItemEcosystemTags::Action_Open;
Mode = EItemInteractionMode::OpenLootUI;
Priority = 700;
DisplayText = NSLOCTEXT("ItemInteraction", "Open", "Open");
}
// --- Inspect ---
UInteractionAction_Inspect::UInteractionAction_Inspect()
{
ActionTag = ItemEcosystemTags::Action_Inspect;
Mode = EItemInteractionMode::Inspect;
Priority = 400;
DisplayText = NSLOCTEXT("ItemInteraction", "Inspect", "Inspect");
}
void UInteractionAction_Inspect::Execute_Implementation(const FInteractionContext& Context)
{
UE_LOG(LogItemInteraction, Verbose, TEXT("Inspect %s"), *GetNameSafe(Context.TargetActor));
}
// --- Combine / Transfer / Split / Merge ---
// These are inventory-UI verbs; their execution runs through the interaction
// component's inventory RPCs. The classes exist as authoring units (section 16).
UInteractionAction_Combine::UInteractionAction_Combine()
{
Mode = EItemInteractionMode::Combine;
Priority = 300;
DisplayText = NSLOCTEXT("ItemInteraction", "Combine", "Combine");
}
UInteractionAction_TransferContainerItem::UInteractionAction_TransferContainerItem()
{
Mode = EItemInteractionMode::TransferContainerItem;
Priority = 300;
DisplayText = NSLOCTEXT("ItemInteraction", "Transfer", "Transfer");
}
UInteractionAction_SplitStack::UInteractionAction_SplitStack()
{
Mode = EItemInteractionMode::SplitStack;
Priority = 300;
DisplayText = NSLOCTEXT("ItemInteraction", "Split", "Split Stack");
}
UInteractionAction_MergeStack::UInteractionAction_MergeStack()
{
Mode = EItemInteractionMode::MergeStack;
Priority = 300;
DisplayText = NSLOCTEXT("ItemInteraction", "Merge", "Merge Stack");
}

View File

@ -0,0 +1,520 @@
// 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);
}

View File

@ -0,0 +1,8 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "Modules/ModuleManager.h"
#include "ItemInteractionLog.h"
DEFINE_LOG_CATEGORY(LogItemInteraction);
IMPLEMENT_MODULE(FDefaultModuleImpl, ItemInteraction)