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:
28
Source/ItemInteraction/ItemInteraction.Build.cs
Normal file
28
Source/ItemInteraction/ItemInteraction.Build.cs
Normal file
@ -0,0 +1,28 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
using UnrealBuildTool;
|
||||
|
||||
public class ItemInteraction : ModuleRules
|
||||
{
|
||||
public ItemInteraction(ReadOnlyTargetRules Target) : base(Target)
|
||||
{
|
||||
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
IWYUSupport = IWYUSupport.Full;
|
||||
|
||||
PublicDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"Core",
|
||||
"CoreUObject",
|
||||
"Engine",
|
||||
"GameplayTags",
|
||||
"NetCore",
|
||||
"ItemCore",
|
||||
"ItemDatabase",
|
||||
"ItemInventory"
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
});
|
||||
}
|
||||
}
|
||||
9
Source/ItemInteraction/Private/InteractableComponent.cpp
Normal file
9
Source/ItemInteraction/Private/InteractableComponent.cpp
Normal file
@ -0,0 +1,9 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
#include "InteractableComponent.h"
|
||||
|
||||
UInteractableComponent::UInteractableComponent()
|
||||
{
|
||||
PrimaryComponentTick.bCanEverTick = false;
|
||||
SetIsReplicatedByDefault(true);
|
||||
}
|
||||
162
Source/ItemInteraction/Private/InteractionAction.cpp
Normal file
162
Source/ItemInteraction/Private/InteractionAction.cpp
Normal 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;
|
||||
}
|
||||
337
Source/ItemInteraction/Private/InteractionResolver.cpp
Normal file
337
Source/ItemInteraction/Private/InteractionResolver.cpp
Normal 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
|
||||
86
Source/ItemInteraction/Private/InteractionTraceComponent.cpp
Normal file
86
Source/ItemInteraction/Private/InteractionTraceComponent.cpp
Normal 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);
|
||||
}
|
||||
97
Source/ItemInteraction/Private/ItemInteractionActions.cpp
Normal file
97
Source/ItemInteraction/Private/ItemInteractionActions.cpp
Normal 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");
|
||||
}
|
||||
520
Source/ItemInteraction/Private/ItemInteractionComponent.cpp
Normal file
520
Source/ItemInteraction/Private/ItemInteractionComponent.cpp
Normal 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);
|
||||
}
|
||||
8
Source/ItemInteraction/Private/ItemInteractionModule.cpp
Normal file
8
Source/ItemInteraction/Private/ItemInteractionModule.cpp
Normal 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)
|
||||
48
Source/ItemInteraction/Public/InteractableComponent.h
Normal file
48
Source/ItemInteraction/Public/InteractableComponent.h
Normal file
@ -0,0 +1,48 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "GameplayTagContainer.h"
|
||||
#include "InteractableComponent.generated.h"
|
||||
|
||||
class UInteractionAction;
|
||||
|
||||
/**
|
||||
* Marks an actor as interactable and lists the actions it offers (section 8.1).
|
||||
* Attach to pickups, chests, vehicles, generators, doors, workbenches, corpses,
|
||||
* placed items, quest objects, etc.
|
||||
*/
|
||||
UCLASS(ClassGroup = (Interaction), meta = (BlueprintSpawnableComponent))
|
||||
class ITEMINTERACTION_API UInteractableComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UInteractableComponent();
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
|
||||
FGameplayTag InteractableType;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
|
||||
FText DisplayName;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
|
||||
float InteractionDistance = 250.f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
|
||||
bool bRequiresLineOfSight = true;
|
||||
|
||||
/** Free-form world tags exposed to the interaction context (e.g. Vehicle, Generator). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
|
||||
FGameplayTagContainer InteractionTags;
|
||||
|
||||
/** Explicit action classes this object offers (in addition to data-driven item rows). */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Instanced, Category = "Interaction")
|
||||
TArray<TObjectPtr<UInteractionAction>> AvailableActions;
|
||||
|
||||
/** Server-side soft claim so two players don't interact with the same object at once. */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
bool bIsBusy = false;
|
||||
};
|
||||
65
Source/ItemInteraction/Public/InteractionAction.h
Normal file
65
Source/ItemInteraction/Public/InteractionAction.h
Normal file
@ -0,0 +1,65 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "UObject/Object.h"
|
||||
#include "GameplayTagContainer.h"
|
||||
#include "ItemEnums.h"
|
||||
#include "InteractionTypes.h"
|
||||
#include "InteractionAction.generated.h"
|
||||
|
||||
/**
|
||||
* A single interaction verb, authored as an instanced object on an
|
||||
* InteractableComponent (section 16). This is the Blueprint-extensible path:
|
||||
* built-in modes are also handled directly by the interaction component, but
|
||||
* any custom verb can be added here without C++.
|
||||
*
|
||||
* CanExecute/Execute must be safe to run on the server. CanExecute may also run
|
||||
* on the client for predictive UI, so it must not mutate state.
|
||||
*/
|
||||
UCLASS(Abstract, Blueprintable, EditInlineNew, DefaultToInstanced, CollapseCategories)
|
||||
class ITEMINTERACTION_API UInteractionAction : public UObject
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
|
||||
FGameplayTag ActionTag;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
|
||||
FText DisplayText;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
|
||||
EItemInteractionMode Mode = EItemInteractionMode::Use;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
|
||||
int32 Priority = 300;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
|
||||
bool bRequiresHold = false;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
|
||||
float HoldDuration = 0.f;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
|
||||
FInteractionExecutionData Execution;
|
||||
|
||||
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
|
||||
TArray<FInteractionRequirement> Requirements;
|
||||
|
||||
/** Pure check: are all requirements satisfied? Writes the first failure reason. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Action")
|
||||
bool CanExecute(const FInteractionContext& Context, FText& OutDisabledReason) const;
|
||||
virtual bool CanExecute_Implementation(const FInteractionContext& Context, FText& OutDisabledReason) const;
|
||||
|
||||
/** Server-authoritative effect. */
|
||||
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Action")
|
||||
void Execute(const FInteractionContext& Context);
|
||||
virtual void Execute_Implementation(const FInteractionContext& Context);
|
||||
|
||||
/** Turns this action into a UI option for the given context. */
|
||||
FInteractionOption BuildOption(const FInteractionContext& Context) const;
|
||||
|
||||
virtual UWorld* GetWorld() const override;
|
||||
};
|
||||
44
Source/ItemInteraction/Public/InteractionResolver.h
Normal file
44
Source/ItemInteraction/Public/InteractionResolver.h
Normal file
@ -0,0 +1,44 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "UObject/Object.h"
|
||||
#include "InteractionTypes.h"
|
||||
#include "InteractionResolver.generated.h"
|
||||
|
||||
class APawn;
|
||||
|
||||
/**
|
||||
* Central, stateless resolver (section 8.3). Given who is interacting and what
|
||||
* they look at, it produces the ordered list of available options. Run on the
|
||||
* client for predictive UI AND re-run on the server to authorize a request, so
|
||||
* it must be deterministic and side-effect free.
|
||||
*/
|
||||
UCLASS()
|
||||
class ITEMINTERACTION_API UInteractionResolver : public UObject
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/** Assembles the interaction context for an instigator/target pair. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Interaction", meta = (WorldContext = "Instigator"))
|
||||
static FInteractionContext BuildContext(APawn* Instigator, AActor* TargetActor);
|
||||
|
||||
/** All options available, highest priority first (section 8.6). */
|
||||
UFUNCTION(BlueprintCallable, Category = "Interaction")
|
||||
static void GetAvailableInteractions(APawn* Instigator, AActor* TargetActor, TArray<FInteractionOption>& OutOptions);
|
||||
|
||||
/** Convenience: the single best (highest priority, enabled) option. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Interaction")
|
||||
static bool GetPrimaryInteraction(APawn* Instigator, AActor* TargetActor, FInteractionOption& OutOption);
|
||||
|
||||
/** Finds the held item on an instigator via IHeldItemInterface (actor or component). */
|
||||
static bool ResolveHeldItem(APawn* Instigator, FItemInstanceData& OutItem);
|
||||
|
||||
private:
|
||||
static void GatherCapabilityOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out);
|
||||
static void GatherContainerOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out);
|
||||
static void GatherDataDrivenOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out);
|
||||
static void GatherActionClassOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out);
|
||||
};
|
||||
60
Source/ItemInteraction/Public/InteractionTraceComponent.h
Normal file
60
Source/ItemInteraction/Public/InteractionTraceComponent.h
Normal file
@ -0,0 +1,60 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "InteractionTraceComponent.generated.h"
|
||||
|
||||
class UInteractableComponent;
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInteractionTargetChanged, AActor*, NewTarget, UInteractableComponent*, NewInteractable);
|
||||
|
||||
/**
|
||||
* Local-only aim trace that finds the best interactable under the crosshair
|
||||
* (section 8.2). Runs only on the owning client; the result drives the prompt UI
|
||||
* and is what the player confirms - never trusted by the server.
|
||||
*/
|
||||
UCLASS(ClassGroup = (Interaction), meta = (BlueprintSpawnableComponent))
|
||||
class ITEMINTERACTION_API UInteractionTraceComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UInteractionTraceComponent();
|
||||
|
||||
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
|
||||
|
||||
/** Fires the trace once and updates CurrentTarget. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Interaction")
|
||||
void TickTrace();
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
float TraceDistance = 300.f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
float TraceRadius = 12.f;
|
||||
|
||||
/** Trace channel; defaults to Visibility. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
TEnumAsByte<ECollisionChannel> TraceChannel = ECC_Visibility;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
TObjectPtr<AActor> CurrentTarget = nullptr;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
TObjectPtr<UInteractableComponent> CurrentInteractable = nullptr;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FVector LastHitLocation = FVector::ZeroVector;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FVector LastHitNormal = FVector::ZeroVector;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Interaction")
|
||||
FOnInteractionTargetChanged OnTargetChanged;
|
||||
|
||||
private:
|
||||
bool GetViewPoint(FVector& OutLocation, FRotator& OutRotation) const;
|
||||
void SetTarget(AActor* NewTarget, UInteractableComponent* NewInteractable);
|
||||
};
|
||||
215
Source/ItemInteraction/Public/InteractionTypes.h
Normal file
215
Source/ItemInteraction/Public/InteractionTypes.h
Normal file
@ -0,0 +1,215 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameplayTagContainer.h"
|
||||
#include "ItemEnums.h"
|
||||
#include "ItemInstanceData.h"
|
||||
#include "InteractionTypes.generated.h"
|
||||
|
||||
class APlayerController;
|
||||
class ACharacter;
|
||||
class UInteractableComponent;
|
||||
class UInventoryComponent;
|
||||
class UTexture2D;
|
||||
|
||||
/**
|
||||
* Client -> server intent (section 9). The client never mutates state; it sends
|
||||
* this and the server re-validates everything before executing.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct ITEMINTERACTION_API FInteractionRequest
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
FGuid RequestId;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
EItemInteractionMode Mode = EItemInteractionMode::None;
|
||||
|
||||
/** The verb that produced this request; lets the server pick the matching action. */
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
FGameplayTag ActionTag;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
TObjectPtr<AActor> TargetActor = nullptr;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
FGuid ItemInstanceId;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
FGuid SourceContainerId;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
FGuid TargetContainerId;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
int32 TargetSlotIndex = INDEX_NONE;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
FGameplayTag TargetSlotTag;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
FTransform DesiredTransform = FTransform::Identity;
|
||||
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
int32 Quantity = 1;
|
||||
|
||||
/** Last inventory revision the client had; lets the server detect desync. */
|
||||
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
|
||||
int32 ClientRevision = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single interaction the player could perform on the current target (section 8.4).
|
||||
* Built by the resolver, shown by the UI, and carries the ready-to-send Request.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct ITEMINTERACTION_API FInteractionOption
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FGameplayTag ActionTag;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
EItemInteractionMode Mode = EItemInteractionMode::None;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FText DisplayText;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
TObjectPtr<UTexture2D> Icon = nullptr;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
int32 Priority = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
bool bRequiresHold = false;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
float HoldDuration = 0.f;
|
||||
|
||||
/** Duration of the action itself once started (section 19). */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
float ActionDuration = 0.f;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
bool bIsEnabled = true;
|
||||
|
||||
/** Why the option is greyed out, e.g. "Inventory Full", "Requires Wrench". */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FText DisabledReason;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FInteractionRequest Request;
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything an action/requirement needs to make a decision (section 17).
|
||||
* Assembled by the resolver on whichever machine is evaluating (client preview
|
||||
* or server validation).
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct ITEMINTERACTION_API FInteractionContext
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
TObjectPtr<APlayerController> PlayerController = nullptr;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
TObjectPtr<ACharacter> Character = nullptr;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
TObjectPtr<AActor> TargetActor = nullptr;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
TObjectPtr<UInteractableComponent> TargetInteractable = nullptr;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
TObjectPtr<UInventoryComponent> PlayerInventory = nullptr;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
bool bHasHeldItem = false;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FItemInstanceData HeldItem;
|
||||
|
||||
/** The item the target itself represents (for pickups), if any. */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
bool bHasTargetItem = false;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FItemInstanceData TargetItem;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FGameplayTagContainer WorldTags;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FVector HitLocation = FVector::ZeroVector;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FVector HitNormal = FVector::ZeroVector;
|
||||
};
|
||||
|
||||
/** Requirement type verbs (section 18). */
|
||||
UENUM(BlueprintType)
|
||||
enum class EInteractionRequirementType : uint8
|
||||
{
|
||||
None,
|
||||
HasInventorySpace,
|
||||
HasItemWithTag,
|
||||
HasHeldItemWithTag,
|
||||
PropertyGreaterThan,
|
||||
PropertyLessThan,
|
||||
TargetHasFreeSlotTag,
|
||||
NotWet,
|
||||
NotBroken
|
||||
};
|
||||
|
||||
/** One requirement gate on an interaction (section 18). */
|
||||
USTRUCT(BlueprintType)
|
||||
struct ITEMINTERACTION_API FInteractionRequirement
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
EInteractionRequirementType RequirementType = EInteractionRequirementType::None;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
FGameplayTag RequiredTag;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
FName RequiredItemId;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
float RequiredValue = 0.f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
FText FailureText;
|
||||
};
|
||||
|
||||
/** Timing / interruption data for an action (section 19). */
|
||||
USTRUCT(BlueprintType)
|
||||
struct ITEMINTERACTION_API FInteractionExecutionData
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
bool bHasDuration = false;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
float Duration = 0.f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
bool bCanBeInterrupted = true;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
bool bLockItemDuringAction = true;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
|
||||
bool bLockTargetDuringAction = false;
|
||||
};
|
||||
72
Source/ItemInteraction/Public/ItemInteractionActions.h
Normal file
72
Source/ItemInteraction/Public/ItemInteractionActions.h
Normal file
@ -0,0 +1,72 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "InteractionAction.h"
|
||||
#include "ItemInteractionActions.generated.h"
|
||||
|
||||
/**
|
||||
* Concrete interaction actions (section 16). These are the Blueprint-droppable
|
||||
* authoring units: add one to an InteractableComponent's AvailableActions and the
|
||||
* resolver/executor will surface and run it. Built-in modes are also dispatched
|
||||
* directly by the interaction component, so both paths reach the same effect.
|
||||
*/
|
||||
|
||||
UCLASS(meta = (DisplayName = "Action: Instant Pickup"))
|
||||
class ITEMINTERACTION_API UInteractionAction_InstantPickup : public UInteractionAction
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UInteractionAction_InstantPickup();
|
||||
virtual void Execute_Implementation(const FInteractionContext& Context) override;
|
||||
};
|
||||
|
||||
UCLASS(meta = (DisplayName = "Action: Open Loot UI"))
|
||||
class ITEMINTERACTION_API UInteractionAction_OpenLootUI : public UInteractionAction
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UInteractionAction_OpenLootUI();
|
||||
};
|
||||
|
||||
UCLASS(meta = (DisplayName = "Action: Inspect"))
|
||||
class ITEMINTERACTION_API UInteractionAction_Inspect : public UInteractionAction
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UInteractionAction_Inspect();
|
||||
virtual void Execute_Implementation(const FInteractionContext& Context) override;
|
||||
};
|
||||
|
||||
UCLASS(meta = (DisplayName = "Action: Combine"))
|
||||
class ITEMINTERACTION_API UInteractionAction_Combine : public UInteractionAction
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UInteractionAction_Combine();
|
||||
};
|
||||
|
||||
UCLASS(meta = (DisplayName = "Action: Transfer Container Item"))
|
||||
class ITEMINTERACTION_API UInteractionAction_TransferContainerItem : public UInteractionAction
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UInteractionAction_TransferContainerItem();
|
||||
};
|
||||
|
||||
UCLASS(meta = (DisplayName = "Action: Split Stack"))
|
||||
class ITEMINTERACTION_API UInteractionAction_SplitStack : public UInteractionAction
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UInteractionAction_SplitStack();
|
||||
};
|
||||
|
||||
UCLASS(meta = (DisplayName = "Action: Merge Stack"))
|
||||
class ITEMINTERACTION_API UInteractionAction_MergeStack : public UInteractionAction
|
||||
{
|
||||
GENERATED_BODY()
|
||||
public:
|
||||
UInteractionAction_MergeStack();
|
||||
};
|
||||
178
Source/ItemInteraction/Public/ItemInteractionComponent.h
Normal file
178
Source/ItemInteraction/Public/ItemInteractionComponent.h
Normal file
@ -0,0 +1,178 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Components/ActorComponent.h"
|
||||
#include "InteractionTypes.h"
|
||||
#include "ItemInteractionComponent.generated.h"
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInteractionResult, FGuid, RequestId, bool, bSuccess);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnOpenContainer, AActor*, ContainerActor);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnActionStarted, FGameplayTag, ActionTag, float, Duration);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnActionFinished, FGameplayTag, ActionTag, bool, bCompleted);
|
||||
|
||||
/** Replicated snapshot of a long/hold action in progress (section 19), for UI. */
|
||||
USTRUCT(BlueprintType)
|
||||
struct FActiveInteractionState
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
bool bActive = false;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
FGameplayTag ActionTag;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
|
||||
float Duration = 0.f;
|
||||
};
|
||||
|
||||
/**
|
||||
* Player-side hub that turns a chosen option into a server request and executes
|
||||
* the authorized result (sections 9, 25). Attach to the player pawn alongside
|
||||
* an InventoryComponent and (optionally) an InteractionTraceComponent.
|
||||
*
|
||||
* Flow: UI/input picks an FInteractionOption -> RequestInteraction() -> Server RPC
|
||||
* -> server re-resolves and re-validates -> executes the transaction -> state
|
||||
* replicates back. The client never changes item state itself.
|
||||
*/
|
||||
UCLASS(ClassGroup = (Interaction), meta = (BlueprintSpawnableComponent))
|
||||
class ITEMINTERACTION_API UItemInteractionComponent : public UActorComponent
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UItemInteractionComponent();
|
||||
|
||||
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
|
||||
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
|
||||
|
||||
/** Client entry point: send the chosen option's request to the server. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Interaction")
|
||||
void RequestInteraction(const FInteractionOption& Option);
|
||||
|
||||
/** Client: signal that a hold action's button was released (cancels if mid-action). */
|
||||
UFUNCTION(BlueprintCallable, Category = "Interaction")
|
||||
void ReleaseHold();
|
||||
|
||||
/** Server: cancel any action in progress (bind to damage/stun for interruption). */
|
||||
UFUNCTION(BlueprintCallable, Category = "Interaction")
|
||||
void CancelActiveAction();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Interaction")
|
||||
const FActiveInteractionState& GetActiveAction() const { return ActiveAction; }
|
||||
|
||||
/** Lower-level entry: send a hand-built request. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Interaction")
|
||||
void SendRequest(const FInteractionRequest& Request);
|
||||
|
||||
// --- Inventory UI operations (section 23.3). Client calls, server validates. ---
|
||||
|
||||
/**
|
||||
* Move/transfer an item. StorageActor == null -> rearrange within the player's
|
||||
* own inventory. Otherwise transfer between the player and the storage actor's
|
||||
* inventory (loot). ToContainerId == invalid -> first accepting container.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventory")
|
||||
void RequestMoveItem(AActor* StorageActor, FGuid InstanceId, FGuid ToContainerId, int32 ToSlot);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventory")
|
||||
void RequestSplitStack(FGuid InstanceId, int32 Amount);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventory")
|
||||
void RequestMergeStack(FGuid SourceInstanceId, FGuid TargetInstanceId);
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Inventory")
|
||||
void RequestDropFromInventory(FGuid InstanceId);
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Interaction")
|
||||
FOnInteractionResult OnInteractionResult;
|
||||
|
||||
/** Fired locally when the player chooses to open a container - UI opens the loot window. */
|
||||
UPROPERTY(BlueprintAssignable, Category = "Interaction")
|
||||
FOnOpenContainer OnOpenContainer;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Interaction")
|
||||
FOnActionStarted OnActionStarted;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Interaction")
|
||||
FOnActionFinished OnActionFinished;
|
||||
|
||||
/** Max slack (cm) added to the interactable's own distance when server-validating. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
|
||||
float ServerDistanceTolerance = 75.f;
|
||||
|
||||
protected:
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_RequestInteraction(const FInteractionRequest& Request);
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_MoveItem(AActor* StorageActor, FGuid InstanceId, FGuid ToContainerId, int32 ToSlot);
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_SplitStack(FGuid InstanceId, int32 Amount);
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_MergeStack(FGuid SourceInstanceId, FGuid TargetInstanceId);
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_DropFromInventory(FGuid InstanceId);
|
||||
|
||||
UFUNCTION(Server, Reliable)
|
||||
void Server_ReleaseHold();
|
||||
|
||||
UFUNCTION(Client, Reliable)
|
||||
void Client_InteractionResult(FGuid RequestId, bool bSuccess);
|
||||
|
||||
/** Server hook for dropping an item into the world. Overridden in ItemWorld. */
|
||||
virtual bool HandleDropFromInventory(FGuid InstanceId);
|
||||
|
||||
private:
|
||||
/** Server: full validation pipeline (section 9). Returns the authorized option. */
|
||||
bool AuthorizeRequest(const FInteractionRequest& Request, FInteractionContext& OutContext, FInteractionOption& OutOption) const;
|
||||
|
||||
public:
|
||||
/**
|
||||
* Server: run a mode directly from an already-built context (used by the named
|
||||
* UInteractionAction_* classes so they share the one execution path). Builds a
|
||||
* minimal option/request and dispatches through ExecuteAuthorized (virtual, so
|
||||
* the ItemWorld subclass handles carry/equip/install/use).
|
||||
*/
|
||||
bool ServerExecuteMode(const FInteractionContext& Context, EItemInteractionMode Mode, FGameplayTag ActionTag);
|
||||
|
||||
protected:
|
||||
APawn* GetPawn() const;
|
||||
|
||||
/**
|
||||
* Server: run the authorized option's effect. Virtual so ItemWorld can add the
|
||||
* carry/drop/equip/install/use-on-target modes (which need ItemWorld types)
|
||||
* without ItemInteraction depending on ItemWorld. Base handles pickup + transfer.
|
||||
*/
|
||||
virtual bool ExecuteAuthorized(const FInteractionContext& Context, const FInteractionOption& Option, const FInteractionRequest& Request);
|
||||
|
||||
bool ExecutePickup(const FInteractionContext& Context);
|
||||
bool ExecuteTransfer(const FInteractionContext& Context, const FInteractionRequest& Request);
|
||||
bool ExecuteActionClass(const FInteractionContext& Context, const FInteractionOption& Option);
|
||||
|
||||
// --- Timed / hold action plumbing (section 19) ---
|
||||
void BeginTimedAction(const FInteractionContext& Context, const FInteractionOption& Option, const FInteractionRequest& Request);
|
||||
void CompleteTimedAction();
|
||||
void AbortTimedAction();
|
||||
void LockActionItem(bool bLock);
|
||||
|
||||
UPROPERTY(ReplicatedUsing = OnRep_ActiveAction)
|
||||
FActiveInteractionState ActiveAction;
|
||||
|
||||
UFUNCTION()
|
||||
void OnRep_ActiveAction();
|
||||
|
||||
// Server-side pending state.
|
||||
bool bHasPending = false;
|
||||
bool bPendingHold = false;
|
||||
float PendingEndTime = 0.f;
|
||||
float PendingMaxDistanceSq = 0.f;
|
||||
FInteractionContext PendingContext;
|
||||
FInteractionOption PendingOption;
|
||||
FInteractionRequest PendingRequest;
|
||||
};
|
||||
84
Source/ItemInteraction/Public/ItemInteractionInterfaces.h
Normal file
84
Source/ItemInteraction/Public/ItemInteractionInterfaces.h
Normal file
@ -0,0 +1,84 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "UObject/Interface.h"
|
||||
#include "ItemInstanceData.h"
|
||||
#include "ItemInteractionInterfaces.generated.h"
|
||||
|
||||
/**
|
||||
* Implemented by world actors that represent a concrete item (e.g. APickupItemActor).
|
||||
* Lets the interaction layer read/take the item without ItemInteraction depending on
|
||||
* ItemWorld - the dependency only flows World -> Interaction.
|
||||
*/
|
||||
UINTERFACE(MinimalAPI, BlueprintType)
|
||||
class UWorldItemProvider : public UInterface
|
||||
{
|
||||
GENERATED_BODY()
|
||||
};
|
||||
|
||||
class ITEMINTERACTION_API IWorldItemProvider
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/** Returns the item this actor carries. */
|
||||
virtual bool GetProvidedItem(FItemInstanceData& OutItem) const = 0;
|
||||
|
||||
/** True if the provider is currently locked/claimed by another interaction. */
|
||||
virtual bool IsItemAvailable() const { return true; }
|
||||
|
||||
/** Server: the item was taken into an inventory/hands - tear down the world actor. */
|
||||
virtual void NotifyItemTaken(AActor* Taker) {}
|
||||
|
||||
/** Server: only part of the stack was taken; write back the remaining item. */
|
||||
virtual void UpdateProvidedItem(const FItemInstanceData& RemainingItem) {}
|
||||
|
||||
/** Server: mark this world item as being physically carried by Holder (or released). */
|
||||
virtual void SetCarried(AActor* Holder, bool bCarried) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Implemented by whatever tracks the pawn's currently held/carried item
|
||||
* (the character or its UHeldItemComponent). The resolver queries it to build
|
||||
* "held + target" interactions without depending on ItemWorld.
|
||||
*/
|
||||
UINTERFACE(MinimalAPI, BlueprintType)
|
||||
class UHeldItemInterface : public UInterface
|
||||
{
|
||||
GENERATED_BODY()
|
||||
};
|
||||
|
||||
class ITEMINTERACTION_API IHeldItemInterface
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual bool GetHeldItem(FItemInstanceData& OutItem) const = 0;
|
||||
virtual bool HasHeldItem() const = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Implemented by world objects that react to a held item being used on them
|
||||
* (section 15): pour fuel into a generator, barricade a door, place bait, etc.
|
||||
* Found on the target actor or any of its components.
|
||||
*/
|
||||
UINTERFACE(MinimalAPI, BlueprintType)
|
||||
class UItemUseTarget : public UInterface
|
||||
{
|
||||
GENERATED_BODY()
|
||||
};
|
||||
|
||||
class ITEMINTERACTION_API IItemUseTarget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Server: apply the held item to this target for the given result verb (e.g.
|
||||
* "TransferFuel"). May mutate HeldItem (reduce fuel/charge). Returns true if it
|
||||
* did something - the caller then writes HeldItem back to the held component.
|
||||
*/
|
||||
virtual bool ReceiveHeldUse(FName ResultVerb, FItemInstanceData& HeldItem, AActor* Instigator) = 0;
|
||||
};
|
||||
8
Source/ItemInteraction/Public/ItemInteractionLog.h
Normal file
8
Source/ItemInteraction/Public/ItemInteractionLog.h
Normal file
@ -0,0 +1,8 @@
|
||||
// Copyright ExByte Studios. Item Interaction Ecosystem.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Logging/LogMacros.h"
|
||||
|
||||
ITEMINTERACTION_API DECLARE_LOG_CATEGORY_EXTERN(LogItemInteraction, Log, All);
|
||||
Reference in New Issue
Block a user