Initial commit: Item Interaction Ecosystem plugin (UE5.7)

Server-authoritative, data-driven item/inventory/interaction/carry/equipment/
world-slot/placement/crafting framework. 8 modules, compiles against UE 5.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-06-22 20:49:56 +03:00
commit 7f7e043a88
98 changed files with 9328 additions and 0 deletions

View File

@ -0,0 +1,29 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
using UnrealBuildTool;
public class ItemWorld : ModuleRules
{
public ItemWorld(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
IWYUSupport = IWYUSupport.Full;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"GameplayTags",
"NetCore",
"ItemCore",
"ItemDatabase",
"ItemInventory",
"ItemInteraction"
});
PrivateDependencyModuleNames.AddRange(new string[]
{
});
}
}

View File

@ -0,0 +1,150 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "EquipmentComponent.h"
#include "ItemWorldLog.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemDefinitionRow.h"
#include "InventoryComponent.h"
#include "GameFramework/Character.h"
#include "Components/SkeletalMeshComponent.h"
#include "Net/UnrealNetwork.h"
UEquipmentComponent::UEquipmentComponent()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}
void UEquipmentComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UEquipmentComponent, Equipped);
}
void UEquipmentComponent::OnUnregister()
{
if (HasAuthority())
{
for (const FEquippedItemEntry& Entry : Equipped)
{
if (Entry.EquippedActor)
{
Entry.EquippedActor->Destroy();
}
}
}
Super::OnUnregister();
}
bool UEquipmentComponent::HasAuthority() const
{
return GetOwner() && GetOwner()->HasAuthority();
}
USceneComponent* UEquipmentComponent::GetAttachTarget() const
{
if (const ACharacter* Char = Cast<ACharacter>(GetOwner()))
{
return Char->GetMesh();
}
return GetOwner() ? GetOwner()->GetRootComponent() : nullptr;
}
FName UEquipmentComponent::ResolveSocket(const FGameplayTag& SlotTag) const
{
if (const FName* Found = SlotSockets.Find(SlotTag))
{
return *Found;
}
return DefaultEquipSocket;
}
bool UEquipmentComponent::IsSlotEquipped(FGameplayTag SlotTag) const
{
return Equipped.ContainsByPredicate([&SlotTag](const FEquippedItemEntry& E) { return E.SlotTag == SlotTag; });
}
bool UEquipmentComponent::ServerEquip(FGuid InstanceId, FGameplayTag SlotTag)
{
if (!HasAuthority())
{
return false;
}
UInventoryComponent* Inv = GetOwner()->FindComponentByClass<UInventoryComponent>();
if (!Inv)
{
return false;
}
FItemInstanceData Item;
if (!Inv->FindItem(InstanceId, Item))
{
return false;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(Item.ItemId) : nullptr;
if (!Def || !Def->bCanEquip)
{
return false;
}
// Slot compatibility (if the item restricts equipment slots).
if (!Def->AllowedEquipmentSlots.IsEmpty() && !Def->AllowedEquipmentSlots.HasTag(SlotTag))
{
return false;
}
// Free the slot first.
ServerUnequip(SlotTag);
FEquippedItemEntry Entry;
Entry.SlotTag = SlotTag;
Entry.InstanceId = InstanceId;
Entry.ItemId = Item.ItemId;
if (!Def->EquippedActorClass.IsNull())
{
if (UClass* ActorClass = Def->EquippedActorClass.LoadSynchronous())
{
FActorSpawnParameters Params;
Params.Owner = GetOwner();
Params.Instigator = Cast<APawn>(GetOwner());
AActor* Spawned = GetWorld()->SpawnActor<AActor>(ActorClass, FTransform::Identity, Params);
if (Spawned)
{
Spawned->AttachToComponent(GetAttachTarget(), FAttachmentTransformRules::SnapToTargetIncludingScale, ResolveSocket(SlotTag));
Entry.EquippedActor = Spawned;
}
}
}
Equipped.Add(Entry);
OnEquipmentChanged.Broadcast();
UE_LOG(LogItemWorld, Log, TEXT("[Inventory] equipped %s in %s"), *Item.ItemId.ToString(), *SlotTag.ToString());
return true;
}
bool UEquipmentComponent::ServerUnequip(FGameplayTag SlotTag)
{
if (!HasAuthority())
{
return false;
}
const int32 Index = Equipped.IndexOfByPredicate([&SlotTag](const FEquippedItemEntry& E) { return E.SlotTag == SlotTag; });
if (Index == INDEX_NONE)
{
return false;
}
if (Equipped[Index].EquippedActor)
{
Equipped[Index].EquippedActor->Destroy();
}
Equipped.RemoveAt(Index);
OnEquipmentChanged.Broadcast();
return true;
}
void UEquipmentComponent::OnRep_Equipped()
{
OnEquipmentChanged.Broadcast();
}

View File

@ -0,0 +1,50 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "FuelTankComponent.h"
#include "ItemWorldLog.h"
#include "ItemNativeTags.h"
#include "Net/UnrealNetwork.h"
UFuelTankComponent::UFuelTankComponent()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}
void UFuelTankComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UFuelTankComponent, CurrentFuel);
}
bool UFuelTankComponent::HasAuthority() const
{
return GetOwner() && GetOwner()->HasAuthority();
}
bool UFuelTankComponent::ReceiveHeldUse(FName ResultVerb, FItemInstanceData& HeldItem, AActor* Instigator)
{
if (!HasAuthority() || ResultVerb != FName("TransferFuel"))
{
return false;
}
const float Available = HeldItem.RuntimeProperties.GetFloat(ItemEcosystemTags::Property_FuelAmount, 0.f);
const float Room = FMath::Max(0.f, MaxFuel - CurrentFuel);
const float Moved = FMath::Min3(PourAmount, Available, Room);
if (Moved <= 0.f)
{
return false;
}
CurrentFuel += Moved;
HeldItem.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_FuelAmount, Available - Moved);
OnFuelChanged.Broadcast(CurrentFuel);
UE_LOG(LogItemWorld, Log, TEXT("[Interaction] poured %.1f fuel (now %.1f/%.1f)"), Moved, CurrentFuel, MaxFuel);
return true;
}
void UFuelTankComponent::OnRep_Fuel()
{
OnFuelChanged.Broadcast(CurrentFuel);
}

View File

@ -0,0 +1,336 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "HeldItemComponent.h"
#include "PickupItemActor.h"
#include "ItemWorldLog.h"
#include "ItemNativeTags.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemDefinitionRow.h"
#include "ItemAuxiliaryRows.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "Components/PrimitiveComponent.h"
#include "PhysicsEngine/PhysicsHandleComponent.h"
#include "Net/UnrealNetwork.h"
UHeldItemComponent::UHeldItemComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.bStartWithTickEnabled = false;
SetIsReplicatedByDefault(true);
}
void UHeldItemComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (HeldItem.Mode == EHeldItemMode::CarryPhysicsHandle && HasAuthority())
{
UpdatePhysicsCarryTarget();
}
}
bool UHeldItemComponent::ServerHoldWorldItemPhysics(AActor* WorldItem)
{
if (!HasAuthority() || HeldItem.bHasHeldItem || !WorldItem)
{
return false;
}
IWorldItemProvider* Provider = Cast<IWorldItemProvider>(WorldItem);
FItemInstanceData Item;
if (!Provider || !Provider->IsItemAvailable() || !Provider->GetProvidedItem(Item) || !CanHoldItem(Item))
{
return false;
}
UPrimitiveComponent* Prim = nullptr;
if (APickupItemActor* Pickup = Cast<APickupItemActor>(WorldItem))
{
Prim = Pickup->GetMeshComponent();
}
if (!Prim)
{
Prim = Cast<UPrimitiveComponent>(WorldItem->GetRootComponent());
}
if (!Prim)
{
return false;
}
// Claim the item but keep physics enabled (unlike attached carry).
Provider->SetCarried(GetOwner(), true);
Prim->SetSimulatePhysics(true);
Prim->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
if (!PhysicsHandle)
{
PhysicsHandle = NewObject<UPhysicsHandleComponent>(this, TEXT("CarryPhysicsHandle"));
PhysicsHandle->RegisterComponent();
}
PhysicsHandle->GrabComponentAtLocation(Prim, NAME_None, Prim->GetComponentLocation());
HeldItem.bHasHeldItem = true;
HeldItem.InstanceId = Item.InstanceId;
HeldItem.ItemId = Item.ItemId;
HeldItem.Mode = EHeldItemMode::CarryPhysicsHandle;
HeldItem.HeldWorldActor = WorldItem;
HeldItem.MoveSpeedMultiplier = 0.9f;
HeldItem.Item = Item;
HeldItem.Item.LocationType = EItemLocationType::Held;
ApplyMovementState();
SetComponentTickEnabled(true);
OnHeldItemChanged.Broadcast(HeldItem);
return true;
}
void UHeldItemComponent::UpdatePhysicsCarryTarget()
{
if (!PhysicsHandle || !PhysicsHandle->GetGrabbedComponent() || !GetOwner())
{
return;
}
FRotator ViewRot = GetOwner()->GetActorRotation();
if (const APawn* Pawn = Cast<APawn>(GetOwner()))
{
ViewRot = Pawn->GetControlRotation();
}
const FVector Target = GetOwner()->GetActorLocation() + ViewRot.Vector() * PhysicsHoldDistance + FVector(0, 0, 50.f);
PhysicsHandle->SetTargetLocationAndRotation(Target, ViewRot);
}
void UHeldItemComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UHeldItemComponent, HeldItem);
}
bool UHeldItemComponent::HasAuthority() const
{
return GetOwner() && GetOwner()->HasAuthority();
}
bool UHeldItemComponent::CanHoldItem(const FItemInstanceData& Item) const
{
if (HeldItem.bHasHeldItem || !Item.IsValid())
{
return false;
}
if (const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this))
{
if (const FItemDefinitionRow* Def = DB->GetItemDefinition(Item.ItemId))
{
return Def->bCanWorldCarry;
}
}
return false;
}
bool UHeldItemComponent::ServerHoldWorldItem(AActor* WorldItem)
{
if (!HasAuthority() || HeldItem.bHasHeldItem || !WorldItem)
{
return false;
}
IWorldItemProvider* Provider = Cast<IWorldItemProvider>(WorldItem);
if (!Provider || !Provider->IsItemAvailable())
{
return false;
}
FItemInstanceData Item;
if (!Provider->GetProvidedItem(Item) || !CanHoldItem(Item))
{
return false;
}
// Attach the world actor to the character's carry socket.
USceneComponent* AttachParent = nullptr;
if (const ACharacter* Char = Cast<ACharacter>(GetOwner()))
{
AttachParent = Char->GetMesh();
}
if (!AttachParent)
{
AttachParent = GetOwner()->GetRootComponent();
}
WorldItem->AttachToComponent(AttachParent, FAttachmentTransformRules::SnapToTargetIncludingScale, CarrySocket);
Provider->SetCarried(GetOwner(), true);
// Resolve carry move-speed multiplier from the feature table.
float MoveMult = 1.f;
if (const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this))
{
TArray<FItemFeatureRow> Features;
DB->GetFeatureRows(Item.ItemId, Features);
for (const FItemFeatureRow& Row : Features)
{
if (Row.Param == FName("MoveSpeedMultiplier"))
{
MoveMult = Row.GetFloat(1.f);
break;
}
}
}
HeldItem.bHasHeldItem = true;
HeldItem.InstanceId = Item.InstanceId;
HeldItem.ItemId = Item.ItemId;
HeldItem.Mode = EHeldItemMode::CarryAttached;
HeldItem.HeldWorldActor = WorldItem;
HeldItem.HoldSocket = CarrySocket;
HeldItem.MoveSpeedMultiplier = MoveMult;
HeldItem.bBlocksSprint = MoveMult < 0.9f;
HeldItem.Item = Item;
HeldItem.Item.LocationType = EItemLocationType::Held;
ApplyMovementState();
OnHeldItemChanged.Broadcast(HeldItem);
UE_LOG(LogItemWorld, Log, TEXT("[Interaction] %s carried %s"), *GetNameSafe(GetOwner()), *Item.ItemId.ToString());
return true;
}
void UHeldItemComponent::RequestDrop()
{
Server_Drop();
}
void UHeldItemComponent::Server_Drop_Implementation()
{
ServerDropHeldItem();
}
bool UHeldItemComponent::ServerDropHeldItem()
{
if (!HasAuthority() || !HeldItem.bHasHeldItem)
{
return false;
}
const FVector Fwd = GetOwner()->GetActorForwardVector();
const FTransform DropXf(GetOwner()->GetActorRotation(),
GetOwner()->GetActorLocation() + Fwd * 100.f + FVector(0, 0, 20.f));
if (APickupItemActor* Pickup = Cast<APickupItemActor>(HeldItem.HeldWorldActor))
{
Pickup->ReleaseToWorld(DropXf);
}
else
{
APickupItemActor::SpawnPickupFromInstance(this, HeldItem.Item, DropXf);
if (HeldItem.HeldWorldActor)
{
HeldItem.HeldWorldActor->Destroy();
}
}
// NoiseOnDrop feature: emit a hearing stimulus (section 21.6).
if (const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this))
{
TArray<FItemFeatureRow> Noise;
DB->GetFeatureRows(HeldItem.ItemId, ItemEcosystemTags::Feature_NoiseOnDrop, Noise);
for (const FItemFeatureRow& Row : Noise)
{
if (Row.Param == FName("Radius"))
{
const float Radius = Row.GetFloat(0.f);
if (Radius > 0.f)
{
GetOwner()->MakeNoise(1.f, Cast<APawn>(GetOwner()), DropXf.GetLocation(), Radius);
}
break;
}
}
}
if (PhysicsHandle && PhysicsHandle->GetGrabbedComponent()) { PhysicsHandle->ReleaseComponent(); }
SetComponentTickEnabled(false);
HeldItem = FHeldItemState();
ApplyMovementState();
OnHeldItemChanged.Broadcast(HeldItem);
return true;
}
bool UHeldItemComponent::ServerConsumeHeldItem(FItemInstanceData& OutItem)
{
if (!HasAuthority() || !HeldItem.bHasHeldItem)
{
return false;
}
OutItem = HeldItem.Item;
if (HeldItem.HeldWorldActor)
{
HeldItem.HeldWorldActor->Destroy();
}
if (PhysicsHandle && PhysicsHandle->GetGrabbedComponent()) { PhysicsHandle->ReleaseComponent(); }
SetComponentTickEnabled(false);
HeldItem = FHeldItemState();
ApplyMovementState();
OnHeldItemChanged.Broadcast(HeldItem);
return true;
}
bool UHeldItemComponent::ServerHandOffHeldItem(FItemInstanceData& OutItem, AActor*& OutActor)
{
if (!HasAuthority() || !HeldItem.bHasHeldItem)
{
return false;
}
OutItem = HeldItem.Item;
OutActor = HeldItem.HeldWorldActor;
if (OutActor)
{
OutActor->DetachFromActor(FDetachmentTransformRules::KeepWorldTransform);
}
if (PhysicsHandle && PhysicsHandle->GetGrabbedComponent()) { PhysicsHandle->ReleaseComponent(); }
SetComponentTickEnabled(false);
HeldItem = FHeldItemState();
ApplyMovementState();
OnHeldItemChanged.Broadcast(HeldItem);
return true;
}
bool UHeldItemComponent::ServerUpdateHeldItem(const FItemInstanceData& NewItem)
{
if (!HasAuthority() || !HeldItem.bHasHeldItem || NewItem.InstanceId != HeldItem.InstanceId)
{
return false;
}
HeldItem.Item = NewItem;
HeldItem.Item.LocationType = EItemLocationType::Held;
OnHeldItemChanged.Broadcast(HeldItem);
return true;
}
bool UHeldItemComponent::GetHeldItem(FItemInstanceData& OutItem) const
{
if (!HeldItem.bHasHeldItem)
{
return false;
}
OutItem = HeldItem.Item;
return true;
}
void UHeldItemComponent::OnRep_HeldItem(FHeldItemState /*OldState*/)
{
ApplyMovementState();
OnHeldItemChanged.Broadcast(HeldItem);
}
void UHeldItemComponent::ApplyMovementState()
{
ACharacter* Char = Cast<ACharacter>(GetOwner());
UCharacterMovementComponent* Move = Char ? Char->GetCharacterMovement() : nullptr;
if (!Move)
{
return;
}
// Cache the unmodified speed once, then re-derive from it each time.
if (CachedBaseWalkSpeed <= 0.f)
{
CachedBaseWalkSpeed = Move->MaxWalkSpeed;
}
const float Mult = HeldItem.bHasHeldItem ? HeldItem.MoveSpeedMultiplier : 1.f;
Move->MaxWalkSpeed = CachedBaseWalkSpeed * Mult;
}

View File

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

View File

@ -0,0 +1,206 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "PickupItemActor.h"
#include "ItemWorldLog.h"
#include "InteractableComponent.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemDefinitionRow.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Net/UnrealNetwork.h"
APickupItemActor::APickupItemActor()
{
bReplicates = true;
SetReplicateMovement(true);
PrimaryActorTick.bCanEverTick = false;
Root = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
SetRootComponent(Root);
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
Mesh->SetupAttachment(Root);
Mesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
Mesh->SetCollisionResponseToAllChannels(ECR_Block);
Mesh->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap);
Interactable = CreateDefaultSubobject<UInteractableComponent>(TEXT("Interactable"));
}
void APickupItemActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(APickupItemActor, ItemData);
DOREPLIFETIME(APickupItemActor, bIsLocked);
DOREPLIFETIME(APickupItemActor, CurrentHolder);
}
void APickupItemActor::BeginPlay()
{
Super::BeginPlay();
RefreshVisuals();
}
void APickupItemActor::InitializeFromItem(const FItemInstanceData& InItem)
{
if (!HasAuthority())
{
return;
}
ItemData = InItem;
ItemData.EnsureInstanceId();
ItemData.LocationType = EItemLocationType::World;
RefreshVisuals();
}
APickupItemActor* APickupItemActor::SpawnPickup(UObject* WorldContext, FName ItemId, int32 Quantity, const FTransform& Transform)
{
UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldContext);
if (!DB || !DB->HasItem(ItemId))
{
UE_LOG(LogItemWorld, Warning, TEXT("SpawnPickup: unknown item '%s'."), *ItemId.ToString());
return nullptr;
}
const FItemInstanceData Item = DB->MakeItemInstance(ItemId, Quantity);
return SpawnPickupFromInstance(WorldContext, Item, Transform);
}
APickupItemActor* APickupItemActor::SpawnPickupFromInstance(UObject* WorldContext, const FItemInstanceData& Item, const FTransform& Transform)
{
UWorld* World = GEngine ? GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::ReturnNull) : nullptr;
if (!World)
{
return nullptr;
}
// Prefer the item's authored pickup actor class; fall back to the base class.
TSubclassOf<AActor> SpawnClass = APickupItemActor::StaticClass();
if (const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldContext))
{
if (const FItemDefinitionRow* Def = DB->GetItemDefinition(Item.ItemId))
{
if (!Def->PickupActorClass.IsNull())
{
if (UClass* Loaded = Def->PickupActorClass.LoadSynchronous())
{
if (Loaded->IsChildOf(APickupItemActor::StaticClass()))
{
SpawnClass = Loaded;
}
}
}
}
}
FActorSpawnParameters Params;
Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
APickupItemActor* Pickup = World->SpawnActor<APickupItemActor>(SpawnClass, Transform, Params);
if (Pickup)
{
Pickup->InitializeFromItem(Item);
}
return Pickup;
}
bool APickupItemActor::GetProvidedItem(FItemInstanceData& OutItem) const
{
if (!ItemData.IsValid())
{
return false;
}
OutItem = ItemData;
return true;
}
bool APickupItemActor::IsItemAvailable() const
{
return !bIsLocked && ItemData.IsValid();
}
void APickupItemActor::NotifyItemTaken(AActor* Taker)
{
if (!HasAuthority())
{
return;
}
UE_LOG(LogItemWorld, Verbose, TEXT("Pickup %s taken by %s; destroying."), *GetName(), *GetNameSafe(Taker));
Destroy();
}
void APickupItemActor::UpdateProvidedItem(const FItemInstanceData& RemainingItem)
{
if (!HasAuthority())
{
return;
}
if (RemainingItem.Quantity <= 0)
{
Destroy();
return;
}
ItemData = RemainingItem;
ItemData.LocationType = EItemLocationType::World;
RefreshVisuals();
}
void APickupItemActor::SetCarried(AActor* Holder, bool bCarried)
{
if (!HasAuthority())
{
return;
}
bIsLocked = bCarried;
CurrentHolder = bCarried ? Holder : nullptr;
if (Mesh)
{
Mesh->SetSimulatePhysics(false);
Mesh->SetCollisionEnabled(bCarried ? ECollisionEnabled::NoCollision : ECollisionEnabled::QueryAndPhysics);
}
}
void APickupItemActor::ReleaseToWorld(const FTransform& WorldTransform)
{
if (!HasAuthority())
{
return;
}
DetachFromActor(FDetachmentTransformRules::KeepWorldTransform);
SetActorTransform(WorldTransform);
bIsLocked = false;
CurrentHolder = nullptr;
if (Mesh)
{
Mesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
}
if (ItemData.IsValid())
{
ItemData.LocationType = EItemLocationType::World;
}
}
void APickupItemActor::OnRep_ItemData()
{
RefreshVisuals();
}
void APickupItemActor::RefreshVisuals()
{
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(ItemData.ItemId) : nullptr;
if (!Def)
{
return;
}
if (Mesh && !Def->WorldMesh.IsNull())
{
if (UStaticMesh* LoadedMesh = Def->WorldMesh.LoadSynchronous())
{
Mesh->SetStaticMesh(LoadedMesh);
}
}
if (Interactable && Interactable->DisplayName.IsEmpty())
{
Interactable->DisplayName = Def->DisplayName;
}
}

View File

@ -0,0 +1,21 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "StorageContainerActor.h"
#include "InteractableComponent.h"
#include "InventoryComponent.h"
#include "Components/StaticMeshComponent.h"
AStorageContainerActor::AStorageContainerActor()
{
bReplicates = true;
PrimaryActorTick.bCanEverTick = false;
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
SetRootComponent(Mesh);
Interactable = CreateDefaultSubobject<UInteractableComponent>(TEXT("Interactable"));
Storage = CreateDefaultSubobject<UInventoryComponent>(TEXT("Storage"));
// Shared container: visible to whoever opens it, not just an owning client.
Storage->bOwnerOnlyReplication = false;
}

View File

@ -0,0 +1,106 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "WorldInteractionActions.h"
#include "ItemInteractionComponent.h"
#include "ItemNativeTags.h"
#include "GameFramework/Character.h"
namespace
{
bool RouteToComponent(const FInteractionContext& Context, EItemInteractionMode Mode, const FGameplayTag& ActionTag)
{
if (!Context.Character)
{
return false;
}
if (UItemInteractionComponent* Comp = Context.Character->FindComponentByClass<UItemInteractionComponent>())
{
return Comp->ServerExecuteMode(Context, Mode, ActionTag);
}
return false;
}
}
UInteractionAction_CarryAttached::UInteractionAction_CarryAttached()
{
ActionTag = ItemEcosystemTags::Action_Carry;
Mode = EItemInteractionMode::CarryAttached;
Priority = 500;
DisplayText = NSLOCTEXT("ItemWorld", "Carry", "Carry");
}
void UInteractionAction_CarryAttached::Execute_Implementation(const FInteractionContext& Context)
{
RouteToComponent(Context, Mode, ActionTag);
}
UInteractionAction_CarryPhysics::UInteractionAction_CarryPhysics()
{
ActionTag = ItemEcosystemTags::Action_Carry;
Mode = EItemInteractionMode::CarryPhysicsHandle;
Priority = 500;
DisplayText = NSLOCTEXT("ItemWorld", "CarryPhysics", "Pick Up");
}
void UInteractionAction_CarryPhysics::Execute_Implementation(const FInteractionContext& Context)
{
RouteToComponent(Context, Mode, ActionTag);
}
UInteractionAction_Equip::UInteractionAction_Equip()
{
ActionTag = ItemEcosystemTags::Action_Equip;
Mode = EItemInteractionMode::EquipFromInventory;
Priority = 800;
DisplayText = NSLOCTEXT("ItemWorld", "Equip", "Equip");
}
void UInteractionAction_Equip::Execute_Implementation(const FInteractionContext& Context)
{
RouteToComponent(Context, Mode, ActionTag);
}
UInteractionAction_DropHeldItem::UInteractionAction_DropHeldItem()
{
ActionTag = ItemEcosystemTags::Action_Drop;
Mode = EItemInteractionMode::DropHeldItem;
Priority = 300;
DisplayText = NSLOCTEXT("ItemWorld", "Drop", "Drop");
}
void UInteractionAction_DropHeldItem::Execute_Implementation(const FInteractionContext& Context)
{
RouteToComponent(Context, Mode, ActionTag);
}
UInteractionAction_InstallIntoWorldSlot::UInteractionAction_InstallIntoWorldSlot()
{
ActionTag = ItemEcosystemTags::Action_Install;
Mode = EItemInteractionMode::InstallIntoWorldSlot;
Priority = 900;
DisplayText = NSLOCTEXT("ItemWorld", "Install", "Install");
}
void UInteractionAction_InstallIntoWorldSlot::Execute_Implementation(const FInteractionContext& Context)
{
RouteToComponent(Context, Mode, ActionTag);
}
UInteractionAction_RemoveFromWorldSlot::UInteractionAction_RemoveFromWorldSlot()
{
ActionTag = ItemEcosystemTags::Action_Remove;
Mode = EItemInteractionMode::RemoveFromWorldSlot;
Priority = 400;
DisplayText = NSLOCTEXT("ItemWorld", "Remove", "Remove");
}
void UInteractionAction_RemoveFromWorldSlot::Execute_Implementation(const FInteractionContext& Context)
{
RouteToComponent(Context, Mode, ActionTag);
}
UInteractionAction_UseHeldOnTarget::UInteractionAction_UseHeldOnTarget()
{
ActionTag = ItemEcosystemTags::Action_UseHeldOnTarget;
Mode = EItemInteractionMode::UseHeldOnTarget;
Priority = 800;
DisplayText = NSLOCTEXT("ItemWorld", "Use", "Use");
}
void UInteractionAction_UseHeldOnTarget::Execute_Implementation(const FInteractionContext& Context)
{
RouteToComponent(Context, Mode, ActionTag);
}

View File

@ -0,0 +1,261 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "WorldInteractionComponent.h"
#include "HeldItemComponent.h"
#include "EquipmentComponent.h"
#include "WorldItemSlotComponent.h"
#include "PickupItemActor.h"
#include "ItemWorldLog.h"
#include "InventoryComponent.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemDefinitionRow.h"
#include "ItemAuxiliaryRows.h"
#include "ItemInteractionInterfaces.h"
#include "GameFramework/Pawn.h"
bool UWorldInteractionComponent::ExecuteAuthorized(const FInteractionContext& Context, const FInteractionOption& Option, const FInteractionRequest& Request)
{
switch (Option.Mode)
{
case EItemInteractionMode::CarryAttached:
return ExecuteCarry(Context, /*bPhysics*/ false);
case EItemInteractionMode::CarryPhysicsHandle:
return ExecuteCarry(Context, /*bPhysics*/ true);
case EItemInteractionMode::DropHeldItem:
return ExecuteDropHeld(Context);
case EItemInteractionMode::InstallIntoWorldSlot:
return ExecuteInstall(Context);
case EItemInteractionMode::RemoveFromWorldSlot:
return ExecuteRemoveFromSlot(Context);
case EItemInteractionMode::EquipFromInventory:
return ExecuteEquip(Context, Request);
case EItemInteractionMode::UseHeldOnTarget:
return ExecuteUseHeldOnTarget(Context, Option);
default:
return Super::ExecuteAuthorized(Context, Option, Request);
}
}
bool UWorldInteractionComponent::ExecuteCarry(const FInteractionContext& Context, bool bPhysics)
{
UHeldItemComponent* Held = GetOwner()->FindComponentByClass<UHeldItemComponent>();
if (!Held || !Context.TargetActor)
{
return false;
}
return bPhysics ? Held->ServerHoldWorldItemPhysics(Context.TargetActor)
: Held->ServerHoldWorldItem(Context.TargetActor);
}
bool UWorldInteractionComponent::ExecuteDropHeld(const FInteractionContext& Context)
{
UHeldItemComponent* Held = GetOwner()->FindComponentByClass<UHeldItemComponent>();
return Held && Held->ServerDropHeldItem();
}
bool UWorldInteractionComponent::ExecuteInstall(const FInteractionContext& Context)
{
UHeldItemComponent* Held = GetOwner()->FindComponentByClass<UHeldItemComponent>();
if (!Held || !Held->HasHeldItem() || !Context.TargetActor)
{
return false;
}
FItemInstanceData HeldItem;
Held->GetHeldItem(HeldItem);
// Find a compatible, free slot on the target.
UWorldItemSlotComponent* Chosen = nullptr;
TArray<UWorldItemSlotComponent*> Slots;
Context.TargetActor->GetComponents<UWorldItemSlotComponent>(Slots);
for (UWorldItemSlotComponent* Slot : Slots)
{
if (Slot && Slot->CanInstall(HeldItem))
{
Chosen = Slot;
break;
}
}
if (!Chosen)
{
UE_LOG(LogItemWorld, Warning, TEXT("[ServerReject] Install failed: no compatible free slot."));
return false;
}
// Hand the carried actor over to the slot as its visual.
FItemInstanceData Item;
AActor* Visual = nullptr;
if (!Held->ServerHandOffHeldItem(Item, Visual))
{
return false;
}
if (!Chosen->InstallItem(Item, Visual))
{
// Roll back: drop the item back to the world so it is not lost.
const FTransform Xf(GetOwner()->GetActorRotation(), GetOwner()->GetActorLocation() + GetOwner()->GetActorForwardVector() * 100.f);
if (APickupItemActor* Pickup = Cast<APickupItemActor>(Visual))
{
Pickup->ReleaseToWorld(Xf);
}
else
{
APickupItemActor::SpawnPickupFromInstance(this, Item, Xf);
if (Visual) { Visual->Destroy(); }
}
return false;
}
return true;
}
bool UWorldInteractionComponent::ExecuteEquip(const FInteractionContext& Context, const FInteractionRequest& Request)
{
UEquipmentComponent* Equip = GetOwner()->FindComponentByClass<UEquipmentComponent>();
if (!Equip)
{
return false;
}
FGameplayTag SlotTag = Request.TargetSlotTag;
if (!SlotTag.IsValid() && Context.PlayerInventory)
{
// Fall back to the item's first allowed equipment slot.
FItemInstanceData Item;
if (Context.PlayerInventory->FindItem(Request.ItemInstanceId, Item))
{
if (const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this))
{
if (const FItemDefinitionRow* Def = DB->GetItemDefinition(Item.ItemId))
{
if (!Def->AllowedEquipmentSlots.IsEmpty())
{
SlotTag = Def->AllowedEquipmentSlots.First();
}
}
}
}
}
return Equip->ServerEquip(Request.ItemInstanceId, SlotTag);
}
bool UWorldInteractionComponent::ExecuteUseHeldOnTarget(const FInteractionContext& Context, const FInteractionOption& Option)
{
UHeldItemComponent* Held = GetOwner()->FindComponentByClass<UHeldItemComponent>();
if (!Held || !Held->HasHeldItem() || !Context.TargetActor)
{
return false;
}
FItemInstanceData HeldItem;
Held->GetHeldItem(HeldItem);
// Find the result verb from the data-driven interaction row for this held item.
FName ResultVerb = NAME_None;
if (const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this))
{
TArray<FItemInteractionRow> Rows;
DB->GetInteractionRows(HeldItem.ItemId, Rows);
for (const FItemInteractionRow& Row : Rows)
{
if (Row.ActionTag == Option.ActionTag) { ResultVerb = Row.Result; break; }
}
}
if (ResultVerb.IsNone())
{
return false;
}
// Locate an IItemUseTarget on the target actor or its components.
IItemUseTarget* UseTarget = Cast<IItemUseTarget>(Context.TargetActor);
if (!UseTarget)
{
for (UActorComponent* Comp : Context.TargetActor->GetComponents())
{
if (IItemUseTarget* AsTarget = Cast<IItemUseTarget>(Comp)) { UseTarget = AsTarget; break; }
}
}
if (!UseTarget)
{
return false;
}
if (UseTarget->ReceiveHeldUse(ResultVerb, HeldItem, GetOwner()))
{
Held->ServerUpdateHeldItem(HeldItem);
return true;
}
return false;
}
bool UWorldInteractionComponent::ExecuteRemoveFromSlot(const FInteractionContext& Context)
{
if (!Context.TargetActor)
{
return false;
}
// Find the first occupied slot on the target.
UWorldItemSlotComponent* Occupied = nullptr;
TArray<UWorldItemSlotComponent*> Slots;
Context.TargetActor->GetComponents<UWorldItemSlotComponent>(Slots);
for (UWorldItemSlotComponent* Slot : Slots)
{
if (Slot && Slot->IsOccupied()) { Occupied = Slot; break; }
}
if (!Occupied)
{
return false;
}
FItemInstanceData Item;
if (!Occupied->RemoveItem(Item))
{
return false;
}
// Prefer the player's inventory; otherwise drop it at their feet.
UInventoryComponent* Inv = Context.PlayerInventory;
if (Inv && Inv->AddItem(Item) && Item.Quantity <= 0)
{
return true;
}
const FTransform Xf(GetOwner()->GetActorRotation(), GetOwner()->GetActorLocation() + GetOwner()->GetActorForwardVector() * 120.f + FVector(0, 0, 30.f));
APickupItemActor::SpawnPickupFromInstance(this, Item, Xf);
return true;
}
bool UWorldInteractionComponent::HandleDropFromInventory(FGuid InstanceId)
{
APawn* Pawn = Cast<APawn>(GetOwner());
UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass<UInventoryComponent>() : nullptr;
if (!Inv)
{
return false;
}
FItemInstanceData Item;
if (!Inv->FindItem(InstanceId, Item) || Item.Lock.bLocked)
{
return false;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(Item.ItemId) : nullptr;
if (Def && !Def->bCanDrop)
{
UE_LOG(LogItemWorld, Warning, TEXT("[ServerReject] Drop failed: %s cannot be dropped."), *Item.ItemId.ToString());
return false;
}
const FTransform Xf(GetOwner()->GetActorRotation(), GetOwner()->GetActorLocation() + GetOwner()->GetActorForwardVector() * 120.f + FVector(0, 0, 20.f));
if (APickupItemActor::SpawnPickupFromInstance(this, Item, Xf))
{
Inv->RemoveItem(InstanceId);
UE_LOG(LogItemWorld, Log, TEXT("[Inventory] dropped %s"), *Item.ItemId.ToString());
return true;
}
return false;
}

View File

@ -0,0 +1,129 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "WorldItemSlotComponent.h"
#include "PickupItemActor.h"
#include "ItemWorldLog.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemDefinitionRow.h"
#include "Net/UnrealNetwork.h"
UWorldItemSlotComponent::UWorldItemSlotComponent()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}
void UWorldItemSlotComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UWorldItemSlotComponent, InstalledItem);
DOREPLIFETIME(UWorldItemSlotComponent, InstalledVisual);
}
bool UWorldItemSlotComponent::HasAuthority() const
{
return GetOwner() && GetOwner()->HasAuthority();
}
USceneComponent* UWorldItemSlotComponent::GetAttachTarget() const
{
return GetOwner() ? GetOwner()->GetRootComponent() : nullptr;
}
bool UWorldItemSlotComponent::CanInstall(const FItemInstanceData& Item) const
{
if (InstalledItem.bHasItem || !Item.IsValid())
{
return false;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(Item.ItemId) : nullptr;
if (!Def || !Def->bCanInstallIntoWorldSlot)
{
return false;
}
// The item must declare this slot as compatible.
if (!Def->CompatibleWorldSlots.HasTag(SlotTag))
{
return false;
}
// Optional extra tag gate.
if (!AcceptedItemTags.IsEmpty())
{
FGameplayTagContainer ItemTags = Def->ItemTags;
ItemTags.AddTag(Def->ItemType);
if (!ItemTags.HasAny(AcceptedItemTags))
{
return false;
}
}
return true;
}
bool UWorldItemSlotComponent::InstallItem(const FItemInstanceData& Item, AActor* ProvidedVisual)
{
if (!HasAuthority() || !CanInstall(Item))
{
return false;
}
InstalledItem.bHasItem = true;
InstalledItem.Item = Item;
InstalledItem.Item.LocationType = EItemLocationType::Installed;
if (ProvidedVisual)
{
// Reuse the carried actor as the visual.
if (USceneComponent* Target = GetAttachTarget())
{
ProvidedVisual->AttachToComponent(Target, FAttachmentTransformRules::SnapToTargetIncludingScale, AttachSocketName);
}
if (IWorldItemProvider* Provider = Cast<IWorldItemProvider>(ProvidedVisual))
{
Provider->SetCarried(GetOwner(), true); // keep collision off, locked
}
InstalledVisual = ProvidedVisual;
}
else
{
// Spawn a fresh visual pickup and attach it.
const FTransform Xf = GetAttachTarget() ? GetAttachTarget()->GetComponentTransform() : GetOwner()->GetActorTransform();
if (APickupItemActor* Visual = APickupItemActor::SpawnPickupFromInstance(this, Item, Xf))
{
Visual->SetCarried(GetOwner(), true);
if (USceneComponent* Target = GetAttachTarget())
{
Visual->AttachToComponent(Target, FAttachmentTransformRules::SnapToTargetIncludingScale, AttachSocketName);
}
InstalledVisual = Visual;
}
}
OnSlotChanged.Broadcast();
UE_LOG(LogItemWorld, Log, TEXT("[Interaction] installed %s into %s"), *Item.ItemId.ToString(), *SlotTag.ToString());
return true;
}
bool UWorldItemSlotComponent::RemoveItem(FItemInstanceData& OutItem)
{
if (!HasAuthority() || !InstalledItem.bHasItem)
{
return false;
}
OutItem = InstalledItem.Item;
if (InstalledVisual)
{
InstalledVisual->Destroy();
InstalledVisual = nullptr;
}
InstalledItem = FWorldSlotInstalledState();
OnSlotChanged.Broadcast();
return true;
}
void UWorldItemSlotComponent::OnRep_InstalledItem()
{
OnSlotChanged.Broadcast();
}

View File

@ -0,0 +1,85 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GameplayTagContainer.h"
#include "ItemInstanceData.h"
#include "EquipmentComponent.generated.h"
/**
* One equipped slot (section 11.2 Equipped). The item itself stays in the
* inventory; only the visual actor is spawned and replicated.
*/
USTRUCT(BlueprintType)
struct FEquippedItemEntry
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Equipment")
FGameplayTag SlotTag;
UPROPERTY(BlueprintReadOnly, Category = "Equipment")
FGuid InstanceId;
UPROPERTY(BlueprintReadOnly, Category = "Equipment")
FName ItemId;
UPROPERTY(BlueprintReadOnly, Category = "Equipment")
TObjectPtr<AActor> EquippedActor = nullptr;
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnEquipmentChanged);
/**
* Spawns/attaches the visual actors for equipped items (section 7, 11.2, 31.5).
*
* Equip keeps the item in the inventory and only manages the EquippedActorClass
* visual - distinct from carry (UHeldItemComponent), which physically removes the
* item from storage. Owner-only item truth, everyone sees the visual (section 22/25).
*/
UCLASS(ClassGroup = (Inventory), meta = (BlueprintSpawnableComponent))
class ITEMWORLD_API UEquipmentComponent : public UActorComponent
{
GENERATED_BODY()
public:
UEquipmentComponent();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
virtual void OnUnregister() override;
/** Socket per slot tag; falls back to DefaultEquipSocket. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Equipment")
TMap<FGameplayTag, FName> SlotSockets;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Equipment")
FName DefaultEquipSocket = FName("hand_rSocket");
UPROPERTY(BlueprintAssignable, Category = "Equipment")
FOnEquipmentChanged OnEquipmentChanged;
/** Server: equip an inventory item into a slot (Inventory -> Equipped). */
bool ServerEquip(FGuid InstanceId, FGameplayTag SlotTag);
/** Server: remove the equipped visual from a slot. */
bool ServerUnequip(FGameplayTag SlotTag);
UFUNCTION(BlueprintPure, Category = "Equipment")
bool IsSlotEquipped(FGameplayTag SlotTag) const;
UFUNCTION(BlueprintPure, Category = "Equipment")
void GetEquippedEntries(TArray<FEquippedItemEntry>& OutEntries) const { OutEntries = Equipped; }
protected:
UPROPERTY(ReplicatedUsing = OnRep_Equipped)
TArray<FEquippedItemEntry> Equipped;
UFUNCTION()
void OnRep_Equipped();
FName ResolveSocket(const FGameplayTag& SlotTag) const;
USceneComponent* GetAttachTarget() const;
bool HasAuthority() const;
};

View File

@ -0,0 +1,48 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "ItemInteractionInterfaces.h"
#include "FuelTankComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnFuelChanged, float, NewFuel);
/**
* A fuel reservoir on a world object (generator, vehicle) that accepts fuel poured
* from a held canister (section 15, 31.4). Implements IItemUseTarget for the
* "TransferFuel" result verb.
*/
UCLASS(ClassGroup = (Interaction), meta = (BlueprintSpawnableComponent))
class ITEMWORLD_API UFuelTankComponent : public UActorComponent, public IItemUseTarget
{
GENERATED_BODY()
public:
UFuelTankComponent();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Fuel")
float MaxFuel = 40.f;
/** Max litres poured per single use action. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Fuel")
float PourAmount = 10.f;
UPROPERTY(ReplicatedUsing = OnRep_Fuel, BlueprintReadOnly, Category = "Fuel")
float CurrentFuel = 0.f;
UPROPERTY(BlueprintAssignable, Category = "Fuel")
FOnFuelChanged OnFuelChanged;
//~ IItemUseTarget
virtual bool ReceiveHeldUse(FName ResultVerb, FItemInstanceData& HeldItem, AActor* Instigator) override;
protected:
UFUNCTION()
void OnRep_Fuel();
bool HasAuthority() const;
};

View File

@ -0,0 +1,151 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GameplayTagContainer.h"
#include "ItemEnums.h"
#include "ItemInstanceData.h"
#include "ItemInteractionInterfaces.h"
#include "HeldItemComponent.generated.h"
class APickupItemActor;
/** Replicated snapshot of what the character is currently holding (section 11.1). */
USTRUCT(BlueprintType)
struct ITEMWORLD_API FHeldItemState
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Held")
bool bHasHeldItem = false;
UPROPERTY(BlueprintReadOnly, Category = "Held")
FGuid InstanceId;
UPROPERTY(BlueprintReadOnly, Category = "Held")
FName ItemId;
UPROPERTY(BlueprintReadOnly, Category = "Held")
EHeldItemMode Mode = EHeldItemMode::None;
UPROPERTY(BlueprintReadOnly, Category = "Held")
TObjectPtr<AActor> HeldWorldActor = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Held")
FName HoldSocket = NAME_None;
UPROPERTY(BlueprintReadOnly, Category = "Held")
float MoveSpeedMultiplier = 1.f;
UPROPERTY(BlueprintReadOnly, Category = "Held")
bool bBlocksSprint = false;
UPROPERTY(BlueprintReadOnly, Category = "Held")
bool bBlocksClimb = false;
/** Snapshot of the carried item (so we can drop/install it without the actor). */
UPROPERTY(BlueprintReadOnly, Category = "Held")
FItemInstanceData Item;
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHeldItemChanged, const FHeldItemState&, NewState);
/**
* Physically carries one bulky item attached to the character (section 11.2-11.3).
*
* This is the "two-handed carry" path for batteries, canisters, wheels, crates,
* corpses, etc. - distinct from equipment (which keeps the item in the inventory
* and only spawns a visual). Server-authoritative; visuals/penalties apply on all
* clients via OnRep. Implements IHeldItemInterface so the resolver can build
* held+target interactions.
*/
UCLASS(ClassGroup = (Inventory), meta = (BlueprintSpawnableComponent))
class ITEMWORLD_API UHeldItemComponent : public UActorComponent, public IHeldItemInterface
{
GENERATED_BODY()
public:
UHeldItemComponent();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Held")
FName CarrySocket = FName("CarrySocket");
/** Hold distance in front of the view for physics-handle carry (section 11.4). */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Held")
float PhysicsHoldDistance = 200.f;
UPROPERTY(BlueprintAssignable, Category = "Held")
FOnHeldItemChanged OnHeldItemChanged;
UFUNCTION(BlueprintPure, Category = "Held")
bool HasHeldItemBP() const { return HeldItem.bHasHeldItem; }
UFUNCTION(BlueprintPure, Category = "Held")
const FHeldItemState& GetHeldItemState() const { return HeldItem; }
UFUNCTION(BlueprintPure, Category = "Held")
bool CanHoldItem(const FItemInstanceData& Item) const;
/** Client entry: ask the server to drop the carried item (bind to the Drop input). */
UFUNCTION(BlueprintCallable, Category = "Held")
void RequestDrop();
// --- Server entry points (section 11.1) ---
/** Server: pick up and carry a world item actor attached to a socket (World -> Held). */
bool ServerHoldWorldItem(AActor* WorldItem);
/** Server: carry a world item via a physics handle instead of a fixed socket (section 11.4). */
bool ServerHoldWorldItemPhysics(AActor* WorldItem);
/** Server: drop the carried item back into the world (Held -> World). */
bool ServerDropHeldItem();
/** Server: relinquish the carried item without spawning a drop (e.g. on install). */
bool ServerConsumeHeldItem(FItemInstanceData& OutItem);
/**
* Server: hand off the carried item AND its actor to a new owner (e.g. a world
* slot) without destroying the actor. Clears the held state. Returns false if
* nothing was held.
*/
bool ServerHandOffHeldItem(FItemInstanceData& OutItem, AActor*& OutActor);
/** Server: update the carried item's data in place (e.g. after pouring out fuel). */
bool ServerUpdateHeldItem(const FItemInstanceData& NewItem);
//~ IHeldItemInterface
virtual bool GetHeldItem(FItemInstanceData& OutItem) const override;
virtual bool HasHeldItem() const override { return HeldItem.bHasHeldItem; }
protected:
UPROPERTY(ReplicatedUsing = OnRep_HeldItem)
FHeldItemState HeldItem;
UFUNCTION(Server, Reliable)
void Server_Drop();
UFUNCTION()
void OnRep_HeldItem(FHeldItemState OldState);
/** Applies/removes the movement-speed penalty on the owning character. */
void ApplyMovementState();
bool HasAuthority() const;
private:
/** Owning character's unmodified MaxWalkSpeed, captured the first time we modify it. */
UPROPERTY(Transient)
float CachedBaseWalkSpeed = 0.f;
/** Created on demand for physics-handle carry. */
UPROPERTY(Transient)
TObjectPtr<class UPhysicsHandleComponent> PhysicsHandle = nullptr;
void UpdatePhysicsCarryTarget();
};

View File

@ -0,0 +1,8 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Logging/LogMacros.h"
ITEMWORLD_API DECLARE_LOG_CATEGORY_EXTERN(LogItemWorld, Log, All);

View File

@ -0,0 +1,82 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ItemInstanceData.h"
#include "ItemInteractionInterfaces.h"
#include "PickupItemActor.generated.h"
class UStaticMeshComponent;
class UInteractableComponent;
/**
* The world representation of an item lying on the ground / a surface (section 12).
*
* One actor per world item only (never one per inventory item - section 32/34).
* Implements IWorldItemProvider so the interaction layer can pick it up, carry it
* or take part of its stack without ItemWorld <-> ItemInteraction coupling.
*/
UCLASS()
class ITEMWORLD_API APickupItemActor : public AActor, public IWorldItemProvider
{
GENERATED_BODY()
public:
APickupItemActor();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
virtual void BeginPlay() override;
/** Server: stamp this pickup with an item and refresh its visuals. */
UFUNCTION(BlueprintCallable, Category = "Item|World")
void InitializeFromItem(const FItemInstanceData& InItem);
/** Server convenience: spawn a fresh pickup for ItemId at a transform. */
UFUNCTION(BlueprintCallable, Category = "Item|World", meta = (WorldContext = "WorldContext"))
static APickupItemActor* SpawnPickup(UObject* WorldContext, FName ItemId, int32 Quantity, const FTransform& Transform);
/** Server convenience: spawn a pickup carrying an existing instance (e.g. on drop). */
static APickupItemActor* SpawnPickupFromInstance(UObject* WorldContext, const FItemInstanceData& Item, const FTransform& Transform);
//~ IWorldItemProvider
virtual bool GetProvidedItem(FItemInstanceData& OutItem) const override;
virtual bool IsItemAvailable() const override;
virtual void NotifyItemTaken(AActor* Taker) override;
virtual void UpdateProvidedItem(const FItemInstanceData& RemainingItem) override;
virtual void SetCarried(AActor* Holder, bool bCarried) override;
/** Server: drop this carried actor back into the world at a transform. */
void ReleaseToWorld(const FTransform& WorldTransform);
UStaticMeshComponent* GetMeshComponent() const { return Mesh; }
UFUNCTION(BlueprintPure, Category = "Item|World")
const FItemInstanceData& GetItemData() const { return ItemData; }
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|World")
TObjectPtr<USceneComponent> Root;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|World")
TObjectPtr<UStaticMeshComponent> Mesh;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|World")
TObjectPtr<UInteractableComponent> Interactable;
UPROPERTY(ReplicatedUsing = OnRep_ItemData, BlueprintReadOnly, Category = "Item|World")
FItemInstanceData ItemData;
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Item|World")
bool bIsLocked = false;
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Item|World")
TObjectPtr<AActor> CurrentHolder = nullptr;
UFUNCTION()
void OnRep_ItemData();
/** Resolves the definition and applies WorldMesh + interactable display name. */
void RefreshVisuals();
};

View File

@ -0,0 +1,38 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "StorageContainerActor.generated.h"
class UStaticMeshComponent;
class UInteractableComponent;
class UInventoryComponent;
/**
* A lootable world container (chest, crate, fridge, vehicle cargo, corpse) (section 5).
* Its inventory is shared-replicated so any nearby player who opens it sees the
* contents; transfers go through the validated server path (section 31.2).
*/
UCLASS()
class ITEMWORLD_API AStorageContainerActor : public AActor
{
GENERATED_BODY()
public:
AStorageContainerActor();
UFUNCTION(BlueprintPure, Category = "Storage")
UInventoryComponent* GetStorageInventory() const { return Storage; }
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage")
TObjectPtr<UStaticMeshComponent> Mesh;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage")
TObjectPtr<UInteractableComponent> Interactable;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage")
TObjectPtr<UInventoryComponent> Storage;
};

View File

@ -0,0 +1,76 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "InteractionAction.h"
#include "WorldInteractionActions.generated.h"
/**
* World interaction actions (section 16). Each routes through the player's
* UItemInteractionComponent::ServerExecuteMode so it shares the one validated
* execution path (carry/equip/install/use live in UWorldInteractionComponent).
*/
UCLASS(meta = (DisplayName = "Action: Carry (Attached)"))
class ITEMWORLD_API UInteractionAction_CarryAttached : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_CarryAttached();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};
UCLASS(meta = (DisplayName = "Action: Carry (Physics)"))
class ITEMWORLD_API UInteractionAction_CarryPhysics : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_CarryPhysics();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};
UCLASS(meta = (DisplayName = "Action: Equip"))
class ITEMWORLD_API UInteractionAction_Equip : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_Equip();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};
UCLASS(meta = (DisplayName = "Action: Drop Held Item"))
class ITEMWORLD_API UInteractionAction_DropHeldItem : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_DropHeldItem();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};
UCLASS(meta = (DisplayName = "Action: Install Into World Slot"))
class ITEMWORLD_API UInteractionAction_InstallIntoWorldSlot : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_InstallIntoWorldSlot();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};
UCLASS(meta = (DisplayName = "Action: Remove From World Slot"))
class ITEMWORLD_API UInteractionAction_RemoveFromWorldSlot : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_RemoveFromWorldSlot();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};
UCLASS(meta = (DisplayName = "Action: Use Held On Target"))
class ITEMWORLD_API UInteractionAction_UseHeldOnTarget : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_UseHeldOnTarget();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};

View File

@ -0,0 +1,31 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "ItemInteractionComponent.h"
#include "WorldInteractionComponent.generated.h"
/**
* The interaction executor to put on a player pawn in a project that uses the
* world modules. Extends the base with the modes that need ItemWorld types:
* carry, drop-held, equip, install/remove from world slots, and drop-from-inventory
* (stages 6-8). Keeps ItemInteraction free of any ItemWorld dependency.
*/
UCLASS(ClassGroup = (Interaction), meta = (BlueprintSpawnableComponent))
class ITEMWORLD_API UWorldInteractionComponent : public UItemInteractionComponent
{
GENERATED_BODY()
protected:
virtual bool ExecuteAuthorized(const FInteractionContext& Context, const FInteractionOption& Option, const FInteractionRequest& Request) override;
virtual bool HandleDropFromInventory(FGuid InstanceId) override;
private:
bool ExecuteCarry(const FInteractionContext& Context, bool bPhysics);
bool ExecuteDropHeld(const FInteractionContext& Context);
bool ExecuteInstall(const FInteractionContext& Context);
bool ExecuteEquip(const FInteractionContext& Context, const FInteractionRequest& Request);
bool ExecuteUseHeldOnTarget(const FInteractionContext& Context, const FInteractionOption& Option);
bool ExecuteRemoveFromSlot(const FInteractionContext& Context);
};

View File

@ -0,0 +1,83 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GameplayTagContainer.h"
#include "ItemInstanceData.h"
#include "WorldItemSlotComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnWorldSlotChanged);
/** Replicated state of a world slot's contents (section 14). */
USTRUCT()
struct FWorldSlotInstalledState
{
GENERATED_BODY()
UPROPERTY()
bool bHasItem = false;
UPROPERTY()
FItemInstanceData Item;
};
/**
* A socket on a world object that accepts an installed item (section 14):
* Vehicle.Socket.Battery, Generator.Socket.Fuel, Door.Socket.BarricadePlank,
* Trap.Socket.Bait, etc. One component per slot.
*/
UCLASS(ClassGroup = (Interaction), meta = (BlueprintSpawnableComponent))
class ITEMWORLD_API UWorldItemSlotComponent : public UActorComponent
{
GENERATED_BODY()
public:
UWorldItemSlotComponent();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
/** This slot's identity; items list it in CompatibleWorldSlots to fit. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Slot")
FGameplayTag SlotTag;
/** Optional extra gate; empty = accept anything whose CompatibleWorldSlots has SlotTag. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Slot")
FGameplayTagContainer AcceptedItemTags;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Slot")
FName AttachSocketName = NAME_None;
UPROPERTY(BlueprintAssignable, Category = "World Slot")
FOnWorldSlotChanged OnSlotChanged;
UFUNCTION(BlueprintPure, Category = "World Slot")
bool IsOccupied() const { return InstalledItem.bHasItem; }
UFUNCTION(BlueprintPure, Category = "World Slot")
bool CanInstall(const FItemInstanceData& Item) const;
UFUNCTION(BlueprintPure, Category = "World Slot")
FItemInstanceData GetInstalledItem() const { return InstalledItem.Item; }
/** Server: install an item. ProvidedVisual (e.g. a carried actor) is reused if given. */
bool InstallItem(const FItemInstanceData& Item, AActor* ProvidedVisual = nullptr);
/** Server: remove and return the installed item. */
bool RemoveItem(FItemInstanceData& OutItem);
protected:
UPROPERTY(ReplicatedUsing = OnRep_InstalledItem)
FWorldSlotInstalledState InstalledItem;
/** Server-spawned visual for the installed item (if not provided externally). */
UPROPERTY(Replicated)
TObjectPtr<AActor> InstalledVisual = nullptr;
UFUNCTION()
void OnRep_InstalledItem();
USceneComponent* GetAttachTarget() const;
bool HasAuthority() const;
};