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:
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
|
||||
Reference in New Issue
Block a user