// 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. 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 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()) { IHeldItemInterface* Held = Cast(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(Instigator); if (Instigator) { Ctx.PlayerController = Cast(Instigator->GetController()); Ctx.PlayerInventory = Instigator->FindComponentByClass(); Ctx.bHasHeldItem = ResolveHeldItem(Instigator, Ctx.HeldItem); } if (TargetActor) { Ctx.TargetInteractable = TargetActor->FindComponentByClass(); if (Ctx.TargetInteractable) { Ctx.WorldTags = Ctx.TargetInteractable->InteractionTags; } if (TargetActor->Implements()) { if (IWorldItemProvider* Provider = Cast(TargetActor)) { Ctx.bHasTargetItem = Provider->GetProvidedItem(Ctx.TargetItem); } } } return Ctx; } void UInteractionResolver::GatherCapabilityOptions(const FInteractionContext& Ctx, TArray& 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& 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(); 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& Out) { const UObject* WorldCtx = Ctx.TargetActor ? static_cast(Ctx.TargetActor) : static_cast(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 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& 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& 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 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