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,27 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
using UnrealBuildTool;
public class ItemInventory : ModuleRules
{
public ItemInventory(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
IWYUSupport = IWYUSupport.Full;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"GameplayTags",
"NetCore",
"ItemCore",
"ItemDatabase"
});
PrivateDependencyModuleNames.AddRange(new string[]
{
});
}
}

View File

@ -0,0 +1,830 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "InventoryComponent.h"
#include "ItemInventoryLog.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemDefinitionRow.h"
#include "Net/UnrealNetwork.h"
#include "Engine/ActorChannel.h"
UInventoryComponent::UInventoryComponent()
{
PrimaryComponentTick.bCanEverTick = false;
bWantsInitializeComponent = true;
SetIsReplicatedByDefault(true);
}
void UInventoryComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
if (bOwnerOnlyReplication)
{
DOREPLIFETIME_CONDITION(UInventoryComponent, ReplicatedItems, COND_OwnerOnly);
DOREPLIFETIME_CONDITION(UInventoryComponent, Containers, COND_OwnerOnly);
}
else
{
DOREPLIFETIME(UInventoryComponent, ReplicatedItems);
DOREPLIFETIME(UInventoryComponent, Containers);
}
}
void UInventoryComponent::OnRegister()
{
Super::OnRegister();
ReplicatedItems.OwnerComponent = this;
}
void UInventoryComponent::InitializeComponent()
{
Super::InitializeComponent();
ReplicatedItems.OwnerComponent = this;
if (GetOwner() && GetOwner()->HasAuthority())
{
for (const FInventoryContainer& Template : DefaultContainers)
{
AddContainer(Template);
}
}
}
bool UInventoryComponent::HasAuthorityChecked(const TCHAR* Op) const
{
if (GetOwner() && GetOwner()->HasAuthority())
{
return true;
}
UE_LOG(LogItemInventory, Warning, TEXT("%s called without authority on %s - ignored."),
Op, *GetNameSafe(GetOwner()));
return false;
}
// --- Containers ---
FGuid UInventoryComponent::AddContainer(FInventoryContainer Container)
{
if (!HasAuthorityChecked(TEXT("AddContainer")))
{
return FGuid();
}
if (!Container.ContainerId.IsValid())
{
Container.ContainerId = FGuid::NewGuid();
}
Containers.Add(Container);
NotifyInventoryChanged();
return Container.ContainerId;
}
FGuid UInventoryComponent::AddContainerByTag(FGameplayTag ContainerTag)
{
if (!HasAuthorityChecked(TEXT("AddContainerByTag")))
{
return FGuid();
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
FContainerDefRow Def;
if (!DB || !DB->FindContainerDef(ContainerTag, Def))
{
UE_LOG(LogItemInventory, Warning, TEXT("AddContainerByTag: no DT_Containers row for %s"), *ContainerTag.ToString());
return FGuid();
}
FInventoryContainer Container;
Container.ContainerTag = Def.ContainerTag;
Container.Type = Def.Type;
Container.Width = Def.Width;
Container.Height = Def.Height;
Container.MaxSlots = Def.MaxSlots;
Container.MaxWeight = Def.MaxWeight;
Container.AcceptedItemTags = Def.AcceptedItemTags;
Container.BlockedItemTags = Def.BlockedItemTags;
return AddContainer(Container);
}
FGuid UInventoryComponent::AddNestedContainerForItem(FGuid OwnerItemInstanceId, FGameplayTag ContainerTag)
{
const FGuid NewId = AddContainerByTag(ContainerTag);
if (NewId.IsValid())
{
if (FInventoryContainer* Container = GetContainerMutable(NewId))
{
Container->OwnerItemInstanceId = OwnerItemInstanceId;
Container->Type = EInventoryContainerType::SlotList; // nested defaults
NotifyInventoryChanged();
}
}
return NewId;
}
bool UInventoryComponent::GetContainer(FGuid ContainerId, FInventoryContainer& OutContainer) const
{
if (const FInventoryContainer* Found = GetContainerConst(ContainerId))
{
OutContainer = *Found;
return true;
}
return false;
}
FGuid UInventoryComponent::FindContainerByTag(FGameplayTag ContainerTag) const
{
for (const FInventoryContainer& C : Containers)
{
if (C.ContainerTag == ContainerTag)
{
return C.ContainerId;
}
}
return FGuid();
}
FInventoryContainer* UInventoryComponent::GetContainerMutable(const FGuid& ContainerId)
{
return Containers.FindByPredicate([&ContainerId](const FInventoryContainer& C) { return C.ContainerId == ContainerId; });
}
const FInventoryContainer* UInventoryComponent::GetContainerConst(const FGuid& ContainerId) const
{
return Containers.FindByPredicate([&ContainerId](const FInventoryContainer& C) { return C.ContainerId == ContainerId; });
}
// --- Queries ---
bool UInventoryComponent::IsSlotOccupied(const FGuid& ContainerId, int32 SlotIndex) const
{
for (const FRepInventoryEntry& E : ReplicatedItems.Entries)
{
if (E.ContainerId == ContainerId && E.SlotIndex == SlotIndex)
{
return true;
}
}
return false;
}
int32 UInventoryComponent::FindFreeSlot(const FGuid& ContainerId) const
{
const FInventoryContainer* Container = GetContainerConst(ContainerId);
if (!Container)
{
return INDEX_NONE;
}
const int32 SlotCount = Container->GetSlotCount();
if (SlotCount > 0)
{
for (int32 Slot = 0; Slot < SlotCount; ++Slot)
{
if (!IsSlotOccupied(ContainerId, Slot))
{
return Slot;
}
}
return INDEX_NONE; // full
}
// Unlimited (weight-gated) list: append after the highest used index.
int32 MaxUsed = INDEX_NONE;
for (const FRepInventoryEntry& E : ReplicatedItems.Entries)
{
if (E.ContainerId == ContainerId)
{
MaxUsed = FMath::Max(MaxUsed, E.SlotIndex);
}
}
return MaxUsed + 1;
}
FIntPoint UInventoryComponent::GetItemGridSize(FName ItemId) const
{
if (const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this))
{
if (const FItemDefinitionRow* Def = DB->GetItemDefinition(ItemId))
{
return FIntPoint(FMath::Max(1, Def->GridSize.X), FMath::Max(1, Def->GridSize.Y));
}
}
return FIntPoint(1, 1);
}
bool UInventoryComponent::IsGridRegionFree(const FInventoryContainer& Container, int32 TopLeft, FIntPoint Size, const FGuid& IgnoreInstance) const
{
const int32 W = FMath::Max(1, Container.Width);
const int32 H = FMath::Max(1, Container.Height);
const int32 X0 = TopLeft % W;
const int32 Y0 = TopLeft / W;
if (X0 + Size.X > W || Y0 + Size.Y > H)
{
return false; // would overflow the grid
}
// Build an occupancy mask from existing entries' footprints.
TArray<bool> Occupied;
Occupied.Init(false, W * H);
for (const FRepInventoryEntry& E : ReplicatedItems.Entries)
{
if (E.ContainerId != Container.ContainerId || E.Item.InstanceId == IgnoreInstance || E.SlotIndex < 0)
{
continue;
}
const FIntPoint ES = GetItemGridSize(E.Item.ItemId);
const int32 EX = E.SlotIndex % W;
const int32 EY = E.SlotIndex / W;
for (int32 dy = 0; dy < ES.Y; ++dy)
{
for (int32 dx = 0; dx < ES.X; ++dx)
{
const int32 CellX = EX + dx;
const int32 CellY = EY + dy;
if (CellX < W && CellY < H) { Occupied[CellY * W + CellX] = true; }
}
}
}
for (int32 dy = 0; dy < Size.Y; ++dy)
{
for (int32 dx = 0; dx < Size.X; ++dx)
{
if (Occupied[(Y0 + dy) * W + (X0 + dx)]) { return false; }
}
}
return true;
}
int32 UInventoryComponent::FindFreeSlotForItem(const FGuid& ContainerId, const FItemInstanceData& Item) const
{
const FInventoryContainer* Container = GetContainerConst(ContainerId);
if (!Container)
{
return INDEX_NONE;
}
if (Container->Type != EInventoryContainerType::Grid)
{
return FindFreeSlot(ContainerId);
}
const int32 W = FMath::Max(1, Container->Width);
const int32 H = FMath::Max(1, Container->Height);
const FIntPoint Size = GetItemGridSize(Item.ItemId);
for (int32 Cell = 0; Cell < W * H; ++Cell)
{
if (IsGridRegionFree(*Container, Cell, Size, FGuid()))
{
return Cell;
}
}
return INDEX_NONE;
}
float UInventoryComponent::GetContainerWeight(FGuid ContainerId) const
{
float Total = 0.f;
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
for (const FRepInventoryEntry& E : ReplicatedItems.Entries)
{
if (E.ContainerId != ContainerId)
{
continue;
}
if (DB)
{
if (const FItemDefinitionRow* Def = DB->GetItemDefinition(E.Item.ItemId))
{
Total += Def->Weight * E.Item.Quantity;
}
}
}
return Total;
}
int32 UInventoryComponent::GetItemCount(FName ItemId) const
{
int32 Count = 0;
for (const FRepInventoryEntry& E : ReplicatedItems.Entries)
{
if (E.Item.ItemId == ItemId)
{
Count += E.Item.Quantity;
}
}
return Count;
}
bool UInventoryComponent::FindItem(FGuid InstanceId, FItemInstanceData& OutItem) const
{
if (const FRepInventoryEntry* Entry = ReplicatedItems.FindByInstanceId(InstanceId))
{
OutItem = Entry->Item;
return true;
}
return false;
}
void UInventoryComponent::GetContainerItems(FGuid ContainerId, TArray<FItemInstanceData>& OutItems) const
{
for (const FRepInventoryEntry& E : ReplicatedItems.Entries)
{
if (E.ContainerId == ContainerId)
{
OutItems.Add(E.Item);
}
}
}
void UInventoryComponent::GetAllItems(TArray<FItemInstanceData>& OutItems) const
{
OutItems.Reserve(ReplicatedItems.Entries.Num());
for (const FRepInventoryEntry& E : ReplicatedItems.Entries)
{
OutItems.Add(E.Item);
}
}
bool UInventoryComponent::CanAcceptItem(const FItemInstanceData& Item, FGuid ContainerId) const
{
const FInventoryContainer* Container = GetContainerConst(ContainerId);
if (!Container || !Item.IsValid())
{
return false;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(Item.ItemId) : nullptr;
if (!Def)
{
return false;
}
// Capability: storage-style containers respect bCanStoreInInventory.
const bool bStorageStyle =
Container->Type == EInventoryContainerType::SlotList ||
Container->Type == EInventoryContainerType::Grid ||
Container->Type == EInventoryContainerType::Hotbar;
if (bStorageStyle && !Def->bCanStoreInInventory)
{
return false;
}
// Tag gating.
FGameplayTagContainer ItemTags = Def->ItemTags;
ItemTags.AddTag(Def->ItemType);
if (!Container->AcceptedItemTags.IsEmpty() && !ItemTags.HasAny(Container->AcceptedItemTags))
{
return false;
}
if (!Container->BlockedItemTags.IsEmpty() && ItemTags.HasAny(Container->BlockedItemTags))
{
return false;
}
// Weight.
if (Container->HasWeightLimit())
{
const float Projected = GetContainerWeight(ContainerId) + Def->Weight * Item.Quantity;
if (Projected > Container->MaxWeight)
{
return false;
}
}
// Space: a free slot, or an existing stack of the same item with headroom.
if (Container->HasSlotLimit())
{
if (FindFreeSlotForItem(ContainerId, Item) != INDEX_NONE)
{
return true;
}
const int32 MaxStack = FMath::Max(1, Def->MaxStack);
for (const FRepInventoryEntry& E : ReplicatedItems.Entries)
{
if (E.ContainerId == ContainerId && E.Item.CanStackWith(Item) && E.Item.Quantity < MaxStack)
{
return true;
}
}
return false;
}
return true;
}
// --- Mutations ---
void UInventoryComponent::StackIntoExisting(const FGuid& ContainerId, FItemInstanceData& Item, int32& Remaining)
{
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const int32 MaxStack = DB ? DB->GetMaxStack(Item.ItemId) : 1;
if (MaxStack <= 1)
{
return;
}
for (FRepInventoryEntry& E : ReplicatedItems.Entries)
{
if (Remaining <= 0)
{
break;
}
if (E.ContainerId == ContainerId && E.Item.CanStackWith(Item) && E.Item.Quantity < MaxStack)
{
const int32 Space = MaxStack - E.Item.Quantity;
const int32 Moved = FMath::Min(Space, Remaining);
E.Item.Quantity += Moved;
Remaining -= Moved;
ReplicatedItems.MarkItemDirty(E);
}
}
}
bool UInventoryComponent::AddItem(FItemInstanceData& Item)
{
if (!HasAuthorityChecked(TEXT("AddItem")))
{
return false;
}
for (const FInventoryContainer& C : Containers)
{
if (CanAcceptItem(Item, C.ContainerId))
{
return AddItemToContainer(Item, C.ContainerId);
}
}
UE_LOG(LogItemInventory, Verbose, TEXT("AddItem: no container accepts %s x%d"), *Item.ItemId.ToString(), Item.Quantity);
return false;
}
void UInventoryComponent::StripServerOnlyProperties(FItemInstanceData& Item)
{
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
if (!DB)
{
return;
}
TArray<FGameplayTag> ServerOnly;
DB->GetServerOnlyProperties(Item.ItemId, ServerOnly);
if (ServerOnly.Num() == 0)
{
return;
}
FItemRuntimeProperties& Side = ServerOnlyProps.FindOrAdd(Item.InstanceId);
for (const FGameplayTag& Tag : ServerOnly)
{
if (Item.RuntimeProperties.HasFloat(Tag))
{
Side.SetFloat(Tag, Item.RuntimeProperties.GetFloat(Tag));
Item.RuntimeProperties.RemoveFloat(Tag); // never replicated to clients
}
}
}
float UInventoryComponent::GetServerOnlyFloat(FGuid InstanceId, FGameplayTag PropertyTag, float Fallback) const
{
if (const FItemRuntimeProperties* Side = ServerOnlyProps.Find(InstanceId))
{
return Side->GetFloat(PropertyTag, Fallback);
}
return Fallback;
}
bool UInventoryComponent::AddItemToContainer(FItemInstanceData& Item, FGuid ContainerId)
{
if (!HasAuthorityChecked(TEXT("AddItemToContainer")))
{
return false;
}
const FInventoryContainer* Container = GetContainerConst(ContainerId);
if (!Container || !Item.IsValid())
{
return false;
}
Item.EnsureInstanceId();
StripServerOnlyProperties(Item);
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const int32 MaxStack = DB ? DB->GetMaxStack(Item.ItemId) : 1;
int32 Remaining = Item.Quantity;
// 1) Top up compatible existing stacks.
StackIntoExisting(ContainerId, Item, Remaining);
// 2) Place leftovers into fresh slots, one stack at a time.
bool bFirstNewStack = true;
while (Remaining > 0)
{
const int32 Slot = FindFreeSlotForItem(ContainerId, Item);
if (Slot == INDEX_NONE)
{
break; // container full
}
const int32 ThisStack = FMath::Min(MaxStack, Remaining);
FItemInstanceData NewStack = Item;
NewStack.Quantity = ThisStack;
NewStack.LocationType = EItemLocationType::Inventory;
// First fresh stack keeps the incoming instance id; subsequent stacks get new ids.
NewStack.InstanceId = bFirstNewStack ? Item.InstanceId : FGuid::NewGuid();
bFirstNewStack = false;
FRepInventoryEntry& Entry = ReplicatedItems.AddOrUpdate(ContainerId, Slot, NewStack);
NotifyItemAdded(Entry.Item);
Remaining -= ThisStack;
}
const int32 AddedCount = Item.Quantity - Remaining;
Item.Quantity = Remaining; // report leftovers back to caller
if (AddedCount > 0)
{
NotifyInventoryChanged();
}
return Remaining == 0;
}
bool UInventoryComponent::RemoveItem(FGuid InstanceId)
{
if (!HasAuthorityChecked(TEXT("RemoveItem")))
{
return false;
}
FItemInstanceData Removed;
const bool bFound = FindItem(InstanceId, Removed);
if (bFound && Removed.Lock.bLocked)
{
UE_LOG(LogItemInventory, Verbose, TEXT("RemoveItem blocked: %s is locked."), *InstanceId.ToString());
return false;
}
if (ReplicatedItems.RemoveByInstanceId(InstanceId))
{
NotifyItemRemoved(Removed);
NotifyInventoryChanged();
return true;
}
return false;
}
bool UInventoryComponent::RemoveQuantity(FGuid InstanceId, int32 Amount)
{
if (!HasAuthorityChecked(TEXT("RemoveQuantity")) || Amount <= 0)
{
return false;
}
FRepInventoryEntry* Entry = ReplicatedItems.FindByInstanceId(InstanceId);
if (!Entry || Entry->Item.Lock.bLocked)
{
return false;
}
if (Amount >= Entry->Item.Quantity)
{
return RemoveItem(InstanceId);
}
Entry->Item.Quantity -= Amount;
ReplicatedItems.MarkItemDirty(*Entry);
NotifyInventoryChanged();
return true;
}
bool UInventoryComponent::MoveItem(FGuid InstanceId, FGuid FromContainerId, FGuid ToContainerId, int32 TargetSlot)
{
if (!HasAuthorityChecked(TEXT("MoveItem")))
{
return false;
}
FRepInventoryEntry* Entry = ReplicatedItems.FindByInstanceId(InstanceId);
if (!Entry || Entry->ContainerId != FromContainerId || Entry->Item.Lock.bLocked)
{
return false;
}
if (!CanAcceptItem(Entry->Item, ToContainerId))
{
return false;
}
// Resolve target slot.
if (TargetSlot == INDEX_NONE)
{
TargetSlot = FindFreeSlot(ToContainerId);
if (TargetSlot == INDEX_NONE)
{
return false;
}
}
// Occupant handling: merge if stackable, else swap.
FRepInventoryEntry* Occupant = nullptr;
for (FRepInventoryEntry& E : ReplicatedItems.Entries)
{
if (E.ContainerId == ToContainerId && E.SlotIndex == TargetSlot && E.Item.InstanceId != InstanceId)
{
Occupant = &E;
break;
}
}
if (Occupant)
{
if (Occupant->Item.CanStackWith(Entry->Item))
{
return MergeStack(InstanceId, Occupant->Item.InstanceId);
}
// Swap positions.
const FGuid FromC = Entry->ContainerId;
const int32 FromS = Entry->SlotIndex;
Occupant->ContainerId = FromC;
Occupant->SlotIndex = FromS;
Occupant->Item.CurrentContainerId = FromC;
Occupant->Item.SlotIndex = FromS;
ReplicatedItems.MarkItemDirty(*Occupant);
}
Entry->ContainerId = ToContainerId;
Entry->SlotIndex = TargetSlot;
Entry->Item.CurrentContainerId = ToContainerId;
Entry->Item.SlotIndex = TargetSlot;
ReplicatedItems.MarkItemDirty(*Entry);
NotifyInventoryChanged();
return true;
}
bool UInventoryComponent::SplitStack(FGuid InstanceId, int32 Amount)
{
if (!HasAuthorityChecked(TEXT("SplitStack")) || Amount <= 0)
{
return false;
}
FRepInventoryEntry* Entry = ReplicatedItems.FindByInstanceId(InstanceId);
if (!Entry || Entry->Item.Lock.bLocked || Amount >= Entry->Item.Quantity)
{
return false;
}
const FGuid ContainerId = Entry->ContainerId;
const int32 FreeSlot = FindFreeSlotForItem(ContainerId, Entry->Item);
if (FreeSlot == INDEX_NONE)
{
return false;
}
Entry->Item.Quantity -= Amount;
ReplicatedItems.MarkItemDirty(*Entry);
FItemInstanceData NewStack = Entry->Item;
NewStack.InstanceId = FGuid::NewGuid();
NewStack.Quantity = Amount;
FRepInventoryEntry& Added = ReplicatedItems.AddOrUpdate(ContainerId, FreeSlot, NewStack);
NotifyItemAdded(Added.Item);
NotifyInventoryChanged();
return true;
}
bool UInventoryComponent::MergeStack(FGuid SourceInstanceId, FGuid TargetInstanceId)
{
if (!HasAuthorityChecked(TEXT("MergeStack")) || SourceInstanceId == TargetInstanceId)
{
return false;
}
FRepInventoryEntry* Source = ReplicatedItems.FindByInstanceId(SourceInstanceId);
FRepInventoryEntry* Target = ReplicatedItems.FindByInstanceId(TargetInstanceId);
if (!Source || !Target || !Target->Item.CanStackWith(Source->Item))
{
return false;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const int32 MaxStack = DB ? DB->GetMaxStack(Target->Item.ItemId) : 1;
const int32 Space = MaxStack - Target->Item.Quantity;
if (Space <= 0)
{
return false;
}
const int32 Moved = FMath::Min(Space, Source->Item.Quantity);
Target->Item.Quantity += Moved;
Source->Item.Quantity -= Moved;
ReplicatedItems.MarkItemDirty(*Target);
if (Source->Item.Quantity <= 0)
{
const FItemInstanceData Removed = Source->Item;
ReplicatedItems.RemoveByInstanceId(SourceInstanceId);
NotifyItemRemoved(Removed);
}
else
{
ReplicatedItems.MarkItemDirty(*Source);
}
NotifyInventoryChanged();
return true;
}
bool UInventoryComponent::UpdateItem(const FItemInstanceData& Item)
{
if (!HasAuthorityChecked(TEXT("UpdateItem")))
{
return false;
}
FRepInventoryEntry* Entry = ReplicatedItems.FindByInstanceId(Item.InstanceId);
if (!Entry)
{
return false;
}
FItemInstanceData Stored = Item;
StripServerOnlyProperties(Stored);
Entry->Item = Stored;
Entry->Item.CurrentContainerId = Entry->ContainerId;
Entry->Item.SlotIndex = Entry->SlotIndex;
ReplicatedItems.MarkItemDirty(*Entry);
NotifyInventoryChanged();
return true;
}
bool UInventoryComponent::SetItemLock(FGuid InstanceId, FGameplayTag Reason, AActor* LockedBy, float ExpireWorldTime)
{
if (!HasAuthorityChecked(TEXT("SetItemLock")))
{
return false;
}
FRepInventoryEntry* Entry = ReplicatedItems.FindByInstanceId(InstanceId);
if (!Entry)
{
return false;
}
Entry->Item.Lock.bLocked = true;
Entry->Item.Lock.LockReason = Reason;
Entry->Item.Lock.LockedBy = LockedBy;
Entry->Item.Lock.LockExpireTime = ExpireWorldTime;
ReplicatedItems.MarkItemDirty(*Entry);
NotifyInventoryChanged();
return true;
}
bool UInventoryComponent::ClearItemLock(FGuid InstanceId)
{
if (!HasAuthorityChecked(TEXT("ClearItemLock")))
{
return false;
}
FRepInventoryEntry* Entry = ReplicatedItems.FindByInstanceId(InstanceId);
if (!Entry)
{
return false;
}
Entry->Item.Lock.Clear();
ReplicatedItems.MarkItemDirty(*Entry);
NotifyInventoryChanged();
return true;
}
void UInventoryComponent::SetItemInSlot(const FItemInstanceData& Item, FGuid ContainerId, int32 SlotIndex)
{
if (!HasAuthorityChecked(TEXT("SetItemInSlot")))
{
return;
}
FItemInstanceData Copy = Item;
Copy.LocationType = EItemLocationType::Inventory;
StripServerOnlyProperties(Copy);
ReplicatedItems.AddOrUpdate(ContainerId, SlotIndex, Copy);
NotifyInventoryChanged();
}
void UInventoryComponent::ClearAllItems()
{
if (!HasAuthorityChecked(TEXT("ClearAllItems")))
{
return;
}
ReplicatedItems.Entries.Reset();
ReplicatedItems.MarkArrayDirty();
NotifyInventoryChanged();
}
void UInventoryComponent::SetContainers(const TArray<FInventoryContainer>& InContainers)
{
if (!HasAuthorityChecked(TEXT("SetContainers")))
{
return;
}
Containers = InContainers;
NotifyInventoryChanged();
}
// --- Notifications ---
void UInventoryComponent::NotifyInventoryChanged()
{
OnInventoryChanged.Broadcast();
}
void UInventoryComponent::NotifyItemAdded(const FItemInstanceData& Item)
{
OnItemAdded.Broadcast(Item);
}
void UInventoryComponent::NotifyItemRemoved(const FItemInstanceData& Item)
{
OnItemRemoved.Broadcast(Item);
}

View File

@ -0,0 +1,98 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemCraftingLibrary.h"
#include "ItemInventoryLog.h"
#include "InventoryComponent.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemContentRows.h"
bool UItemCraftingLibrary::CanCraft(const UObject* WorldContext, UInventoryComponent* Inventory, FName RecipeId, FGameplayTag AvailableStation)
{
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldContext);
if (!DB || !Inventory)
{
return false;
}
FCraftingRecipeRow Recipe;
if (!DB->FindRecipe(RecipeId, Recipe))
{
return false;
}
if (Recipe.StationTag.IsValid() && !AvailableStation.MatchesTag(Recipe.StationTag))
{
return false;
}
for (const FCraftingIngredient& In : Recipe.Inputs)
{
if (Inventory->GetItemCount(In.ItemId) < In.Quantity)
{
return false;
}
}
return true;
}
bool UItemCraftingLibrary::Craft(const UObject* WorldContext, UInventoryComponent* Inventory, FName RecipeId, FGameplayTag AvailableStation)
{
if (!Inventory || !Inventory->GetOwner() || !Inventory->GetOwner()->HasAuthority())
{
return false;
}
if (!CanCraft(WorldContext, Inventory, RecipeId, AvailableStation))
{
return false;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldContext);
FCraftingRecipeRow Recipe;
DB->FindRecipe(RecipeId, Recipe);
// Consume inputs (already verified present).
for (const FCraftingIngredient& In : Recipe.Inputs)
{
if (!ConsumeItem(Inventory, In.ItemId, In.Quantity))
{
UE_LOG(LogItemInventory, Error, TEXT("Craft '%s' failed mid-consume for %s."), *RecipeId.ToString(), *In.ItemId.ToString());
return false;
}
}
FItemInstanceData Output = DB->MakeItemInstance(Recipe.OutputItemId, FMath::Max(1, Recipe.OutputQuantity));
Inventory->AddItem(Output);
UE_LOG(LogItemInventory, Log, TEXT("[Crafting] crafted %s"), *Recipe.OutputItemId.ToString());
return true;
}
void UItemCraftingLibrary::RollLootIntoInventory(const UObject* WorldContext, UInventoryComponent* Inventory, FName TableId, int32 RollCount)
{
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldContext);
if (!DB || !Inventory || !Inventory->GetOwner() || !Inventory->GetOwner()->HasAuthority())
{
return;
}
TArray<FItemInstanceData> Loot;
DB->RollLoot(TableId, RollCount, Loot);
for (FItemInstanceData& Item : Loot)
{
Inventory->AddItem(Item);
}
}
bool UItemCraftingLibrary::ConsumeItem(UInventoryComponent* Inventory, FName ItemId, int32 Count)
{
int32 Remaining = Count;
TArray<FItemInstanceData> Items;
Inventory->GetAllItems(Items);
for (const FItemInstanceData& Item : Items)
{
if (Remaining <= 0) { break; }
if (Item.ItemId != ItemId || Item.Lock.bLocked) { continue; }
const int32 Take = FMath::Min(Remaining, Item.Quantity);
if (Inventory->RemoveQuantity(Item.InstanceId, Take))
{
Remaining -= Take;
}
}
return Remaining <= 0;
}

View File

@ -0,0 +1,165 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemFeatureLibrary.h"
#include "ItemInventoryLog.h"
#include "ItemNativeTags.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemAuxiliaryRows.h"
#include "InventoryComponent.h"
#include "Engine/World.h"
namespace
{
float GetWorldSeconds(const UObject* WorldContext)
{
const UWorld* World = GEngine ? GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::ReturnNull) : nullptr;
return World ? World->GetTimeSeconds() : 0.f;
}
}
bool UItemFeatureLibrary::UpdateLazyFeatures(const UObject* WorldContext, FItemInstanceData& Item, FName& OutTransformItemId)
{
OutTransformItemId = NAME_None;
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldContext);
if (!DB)
{
return false;
}
TArray<FItemFeatureRow> RottingRows;
DB->GetFeatureRows(Item.ItemId, ItemEcosystemTags::Feature_Rotting, RottingRows);
if (RottingRows.Num() == 0)
{
return false; // not perishable
}
float DecayPerHour = 0.f;
FName RottenItemId = NAME_None;
for (const FItemFeatureRow& Row : RottingRows)
{
if (Row.Param == FName("DecayRatePerHour")) { DecayPerHour = Row.GetFloat(); }
else if (Row.Param == FName("RottenItemId")) { RottenItemId = FName(*Row.Value); }
}
if (DecayPerHour <= 0.f)
{
return false;
}
const float Now = GetWorldSeconds(WorldContext);
const float Last = Item.RuntimeProperties.GetFloat(ItemEcosystemTags::Property_LastUpdateTime, Now);
float Freshness = Item.RuntimeProperties.GetFloat(ItemEcosystemTags::Property_Freshness, 1.f);
const float ElapsedHours = FMath::Max(0.f, Now - Last) / 3600.f;
Freshness = FMath::Clamp(Freshness - DecayPerHour * ElapsedHours, 0.f, 1.f);
Item.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_Freshness, Freshness);
Item.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_LastUpdateTime, Now);
if (Freshness <= 0.f)
{
Item.StateTags.AddTag(ItemEcosystemTags::State_Rotten);
if (!RottenItemId.IsNone())
{
OutTransformItemId = RottenItemId;
}
}
return true;
}
void UItemFeatureLibrary::UpdateInventoryFeatures(const UObject* WorldContext, UInventoryComponent* Inventory)
{
if (!Inventory || !Inventory->GetOwner() || !Inventory->GetOwner()->HasAuthority())
{
return;
}
TArray<FItemInstanceData> Items;
Inventory->GetAllItems(Items);
for (FItemInstanceData& Item : Items)
{
FName TransformId;
if (!UpdateLazyFeatures(WorldContext, Item, TransformId))
{
continue;
}
if (!TransformId.IsNone())
{
// Transform into the rotten variant, preserving quantity.
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldContext);
FItemInstanceData NewItem = DB ? DB->MakeItemInstance(TransformId, Item.Quantity) : FItemInstanceData(TransformId, Item.Quantity);
Inventory->RemoveItem(Item.InstanceId);
Inventory->AddItem(NewItem);
}
else
{
Inventory->UpdateItem(Item);
}
}
}
void UItemFeatureLibrary::ForceRot(const UObject* WorldContext, FItemInstanceData& Item, FName& OutTransformItemId)
{
Item.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_Freshness, 0.f);
Item.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_LastUpdateTime, GetWorldSeconds(WorldContext));
Item.StateTags.AddTag(ItemEcosystemTags::State_Rotten);
OutTransformItemId = NAME_None;
if (const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldContext))
{
TArray<FItemFeatureRow> Rows;
DB->GetFeatureRows(Item.ItemId, ItemEcosystemTags::Feature_Rotting, Rows);
for (const FItemFeatureRow& Row : Rows)
{
if (Row.Param == FName("RottenItemId") && !Row.Value.IsEmpty())
{
OutTransformItemId = FName(*Row.Value);
break;
}
}
}
}
void UItemFeatureLibrary::BreakItem(FItemInstanceData& Item)
{
Item.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_Durability, 0.f);
Item.StateTags.AddTag(ItemEcosystemTags::State_Broken);
}
void UItemFeatureLibrary::DrainBattery(FItemInstanceData& Item, float DrainPerSecond, float Seconds)
{
const float Charge = Item.RuntimeProperties.GetFloat(ItemEcosystemTags::Property_BatteryCharge, 1.f);
Item.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_BatteryCharge,
FMath::Clamp(Charge - DrainPerSecond * Seconds, 0.f, 1.f));
}
float UItemFeatureLibrary::AddFuel(FItemInstanceData& Item, float Amount, float MaxFuel)
{
const float Current = Item.RuntimeProperties.GetFloat(ItemEcosystemTags::Property_FuelAmount, 0.f);
const float NewValue = FMath::Clamp(Current + Amount, 0.f, MaxFuel);
Item.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_FuelAmount, NewValue);
return NewValue - Current;
}
float UItemFeatureLibrary::TransferFuel(FItemInstanceData& From, FItemInstanceData& To, float Amount, float ToMaxFuel)
{
const float Available = From.RuntimeProperties.GetFloat(ItemEcosystemTags::Property_FuelAmount, 0.f);
const float ToCurrent = To.RuntimeProperties.GetFloat(ItemEcosystemTags::Property_FuelAmount, 0.f);
const float Room = FMath::Max(0.f, ToMaxFuel - ToCurrent);
const float Moved = FMath::Min3(Amount, Available, Room);
if (Moved <= 0.f)
{
return 0.f;
}
From.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_FuelAmount, Available - Moved);
To.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_FuelAmount, ToCurrent + Moved);
return Moved;
}
void UItemFeatureLibrary::SetWetness(FItemInstanceData& Item, float Wetness)
{
const float Clamped = FMath::Clamp(Wetness, 0.f, 1.f);
Item.RuntimeProperties.SetFloat(ItemEcosystemTags::Property_Wetness, Clamped);
if (Clamped > 0.5f) { Item.StateTags.AddTag(ItemEcosystemTags::State_Wet); }
else { Item.StateTags.RemoveTag(ItemEcosystemTags::State_Wet); }
}

View File

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

View File

@ -0,0 +1,79 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemSaveLibrary.h"
#include "ItemInventoryLog.h"
#include "InventoryComponent.h"
#include "ItemDatabaseSubsystem.h"
#include "Kismet/GameplayStatics.h"
FInventorySaveData UItemSaveLibrary::CaptureInventory(UInventoryComponent* Inventory)
{
FInventorySaveData Data;
if (!Inventory)
{
return Data;
}
Inventory->GetAllItems(Data.Items);
for (FItemInstanceData& Item : Data.Items)
{
Item.Lock.Clear(); // never persist transient locks (section 26)
}
Inventory->GetContainers(Data.Containers);
return Data;
}
void UItemSaveLibrary::RestoreInventory(const UObject* WorldContext, UInventoryComponent* Inventory, const FInventorySaveData& Data)
{
if (!Inventory || !Inventory->GetOwner() || !Inventory->GetOwner()->HasAuthority())
{
return;
}
Inventory->SetContainers(Data.Containers);
Inventory->ClearAllItems();
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldContext);
for (const FItemInstanceData& Saved : Data.Items)
{
FItemInstanceData Item = Saved;
if (DB) { DB->MigrateLoadedItem(Item); }
Inventory->SetItemInSlot(Item, Item.CurrentContainerId, Item.SlotIndex);
}
UE_LOG(LogItemInventory, Log, TEXT("[Save] restored %d items into %s"), Data.Items.Num(), *GetNameSafe(Inventory->GetOwner()));
}
bool UItemSaveLibrary::SaveInventoryToSlot(UInventoryComponent* Inventory, const FString& SlotName, FName Key)
{
if (!Inventory)
{
return false;
}
UItemSaveGame* Save = Cast<UItemSaveGame>(UGameplayStatics::LoadGameFromSlot(SlotName, 0));
if (!Save)
{
Save = Cast<UItemSaveGame>(UGameplayStatics::CreateSaveGameObject(UItemSaveGame::StaticClass()));
}
if (!Save)
{
return false;
}
Save->Inventories.Add(Key, CaptureInventory(Inventory));
return UGameplayStatics::SaveGameToSlot(Save, SlotName, 0);
}
bool UItemSaveLibrary::LoadInventoryFromSlot(const UObject* WorldContext, UInventoryComponent* Inventory, const FString& SlotName, FName Key)
{
UItemSaveGame* Save = Cast<UItemSaveGame>(UGameplayStatics::LoadGameFromSlot(SlotName, 0));
if (!Save)
{
return false;
}
const FInventorySaveData* Data = Save->Inventories.Find(Key);
if (!Data)
{
return false;
}
RestoreInventory(WorldContext, Inventory, *Data);
return true;
}

View File

@ -0,0 +1,173 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemTransaction.h"
#include "ItemInventoryLog.h"
#include "InventoryComponent.h"
FItemTransaction& FItemTransaction::Add(UInventoryComponent* Inventory, const FItemInstanceData& Item)
{
FItemTransactionStep Step;
Step.Type = EItemTransactionStepType::AddItem;
Step.Inventory = Inventory;
Step.Item = Item;
Step.Item.EnsureInstanceId();
Step.InstanceId = Step.Item.InstanceId;
Steps.Add(Step);
return *this;
}
FItemTransaction& FItemTransaction::Remove(UInventoryComponent* Inventory, const FGuid& InstanceId)
{
FItemTransactionStep Step;
Step.Type = EItemTransactionStepType::RemoveItem;
Step.Inventory = Inventory;
Step.InstanceId = InstanceId;
Steps.Add(Step);
return *this;
}
FItemTransaction& FItemTransaction::Update(UInventoryComponent* Inventory, const FItemInstanceData& Item)
{
FItemTransactionStep Step;
Step.Type = EItemTransactionStepType::UpdateItem;
Step.Inventory = Inventory;
Step.Item = Item;
Step.InstanceId = Item.InstanceId;
Steps.Add(Step);
return *this;
}
FItemTransaction& FItemTransaction::Move(UInventoryComponent* Inventory, const FGuid& InstanceId, const FGuid& ToContainerId, int32 ToSlotIndex)
{
FItemTransactionStep Step;
Step.Type = EItemTransactionStepType::MoveItem;
Step.Inventory = Inventory;
Step.InstanceId = InstanceId;
Step.ToContainer = ToContainerId;
Step.ToSlot = ToSlotIndex;
Steps.Add(Step);
return *this;
}
bool FItemTransaction::Validate() const
{
for (const FItemTransactionStep& Step : Steps)
{
UInventoryComponent* Inv = Step.Inventory.Get();
if (!Inv)
{
return false;
}
switch (Step.Type)
{
case EItemTransactionStepType::AddItem:
{
// At least one container must accept it.
TArray<FInventoryContainer> Containers;
Inv->GetContainers(Containers);
bool bAccepts = false;
for (const FInventoryContainer& C : Containers)
{
if (Inv->CanAcceptItem(Step.Item, C.ContainerId)) { bAccepts = true; break; }
}
if (!bAccepts) { return false; }
break;
}
case EItemTransactionStepType::RemoveItem:
case EItemTransactionStepType::UpdateItem:
case EItemTransactionStepType::MoveItem:
{
FItemInstanceData Found;
if (!Inv->FindItem(Step.InstanceId, Found) || Found.Lock.bLocked) { return false; }
break;
}
}
}
return true;
}
bool FItemTransaction::Apply()
{
if (!Validate())
{
return false;
}
for (FItemTransactionStep& Step : Steps)
{
UInventoryComponent* Inv = Step.Inventory.Get();
bool bOk = false;
switch (Step.Type)
{
case EItemTransactionStepType::AddItem:
{
FItemInstanceData ToAdd = Step.Item;
bOk = Inv->AddItem(ToAdd);
break;
}
case EItemTransactionStepType::RemoveItem:
{
Inv->FindItem(Step.InstanceId, Step.UndoSnapshot); // capture for rollback
bOk = Inv->RemoveItem(Step.InstanceId);
break;
}
case EItemTransactionStepType::UpdateItem:
{
Inv->FindItem(Step.InstanceId, Step.UndoSnapshot);
bOk = Inv->UpdateItem(Step.Item);
break;
}
case EItemTransactionStepType::MoveItem:
{
Inv->FindItem(Step.InstanceId, Step.UndoSnapshot);
Step.UndoFromContainer = Step.UndoSnapshot.CurrentContainerId;
Step.UndoFromSlot = Step.UndoSnapshot.SlotIndex;
bOk = Inv->MoveItem(Step.InstanceId, Step.UndoFromContainer, Step.ToContainer, Step.ToSlot);
break;
}
}
if (!bOk)
{
UE_LOG(LogItemInventory, Warning, TEXT("[Transaction %s] step failed; rolling back."), *TransactionId.ToString());
Rollback();
return false;
}
Step.bApplied = true;
}
return true;
}
void FItemTransaction::Rollback()
{
// Undo applied steps in reverse order.
for (int32 i = Steps.Num() - 1; i >= 0; --i)
{
FItemTransactionStep& Step = Steps[i];
if (!Step.bApplied)
{
continue;
}
UInventoryComponent* Inv = Step.Inventory.Get();
if (!Inv)
{
continue;
}
switch (Step.Type)
{
case EItemTransactionStepType::AddItem:
Inv->RemoveItem(Step.InstanceId);
break;
case EItemTransactionStepType::RemoveItem:
if (Step.UndoSnapshot.IsValid()) { FItemInstanceData Restore = Step.UndoSnapshot; Inv->AddItem(Restore); }
break;
case EItemTransactionStepType::UpdateItem:
if (Step.UndoSnapshot.IsValid()) { Inv->UpdateItem(Step.UndoSnapshot); }
break;
case EItemTransactionStepType::MoveItem:
Inv->MoveItem(Step.InstanceId, Step.ToContainer, Step.UndoFromContainer, Step.UndoFromSlot);
break;
}
Step.bApplied = false;
}
}

View File

@ -0,0 +1,103 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ReplicatedInventoryList.h"
#include "InventoryComponent.h"
void FReplicatedInventoryList::PostReplicatedAdd(const TArrayView<int32>& AddedIndices, int32 /*FinalSize*/)
{
if (!OwnerComponent)
{
return;
}
for (int32 Index : AddedIndices)
{
if (Entries.IsValidIndex(Index))
{
OwnerComponent->NotifyItemAdded(Entries[Index].Item);
}
}
OwnerComponent->NotifyInventoryChanged();
}
void FReplicatedInventoryList::PostReplicatedChange(const TArrayView<int32>& /*ChangedIndices*/, int32 /*FinalSize*/)
{
if (OwnerComponent)
{
OwnerComponent->NotifyInventoryChanged();
}
}
void FReplicatedInventoryList::PreReplicatedRemove(const TArrayView<int32>& RemovedIndices, int32 /*FinalSize*/)
{
if (!OwnerComponent)
{
return;
}
for (int32 Index : RemovedIndices)
{
if (Entries.IsValidIndex(Index))
{
OwnerComponent->NotifyItemRemoved(Entries[Index].Item);
}
}
OwnerComponent->NotifyInventoryChanged();
}
FRepInventoryEntry& FReplicatedInventoryList::AddOrUpdate(const FGuid& Container, int32 Slot, const FItemInstanceData& Item)
{
if (FRepInventoryEntry* Existing = FindByInstanceId(Item.InstanceId))
{
Existing->ContainerId = Container;
Existing->SlotIndex = Slot;
Existing->Item = Item;
Existing->Item.CurrentContainerId = Container;
Existing->Item.SlotIndex = Slot;
MarkItemDirty(*Existing);
return *Existing;
}
FRepInventoryEntry& New = Entries.Emplace_GetRef(Container, Slot, Item);
New.Item.CurrentContainerId = Container;
New.Item.SlotIndex = Slot;
MarkItemDirty(New);
return New;
}
bool FReplicatedInventoryList::RemoveByInstanceId(const FGuid& InstanceId)
{
const int32 Index = Entries.IndexOfByPredicate([&InstanceId](const FRepInventoryEntry& E)
{
return E.Item.InstanceId == InstanceId;
});
if (Index == INDEX_NONE)
{
return false;
}
Entries.RemoveAt(Index);
MarkArrayDirty();
return true;
}
void FReplicatedInventoryList::MarkEntryDirtyByInstanceId(const FGuid& InstanceId)
{
if (FRepInventoryEntry* Entry = FindByInstanceId(InstanceId))
{
MarkItemDirty(*Entry);
}
}
FRepInventoryEntry* FReplicatedInventoryList::FindByInstanceId(const FGuid& InstanceId)
{
return Entries.FindByPredicate([&InstanceId](const FRepInventoryEntry& E)
{
return E.Item.InstanceId == InstanceId;
});
}
const FRepInventoryEntry* FReplicatedInventoryList::FindByInstanceId(const FGuid& InstanceId) const
{
return Entries.FindByPredicate([&InstanceId](const FRepInventoryEntry& E)
{
return E.Item.InstanceId == InstanceId;
});
}

View File

@ -0,0 +1,195 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "ReplicatedInventoryList.h"
#include "InventoryContainer.h"
#include "InventoryComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnInventoryChangedSignature);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnInventoryItemEventSignature, const FItemInstanceData&, Item);
/**
* Owns and replicates a set of containers full of items (section 7).
*
* Server-authoritative: every mutating method must run on the server (the
* interaction/transaction layer is the only intended caller). Clients receive
* state through the FastArray and react via OnInventoryChanged / OnRep. The UI
* never mutates this directly (section 25).
*/
UCLASS(ClassGroup = (Inventory), meta = (BlueprintSpawnableComponent))
class ITEMINVENTORY_API UInventoryComponent : public UActorComponent
{
GENERATED_BODY()
public:
UInventoryComponent();
//~ UActorComponent
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
virtual void InitializeComponent() override;
virtual void OnRegister() override;
// --- Events (UI binds here, never polls - section 32) ---
UPROPERTY(BlueprintAssignable, Category = "Inventory")
FOnInventoryChangedSignature OnInventoryChanged;
UPROPERTY(BlueprintAssignable, Category = "Inventory")
FOnInventoryItemEventSignature OnItemAdded;
UPROPERTY(BlueprintAssignable, Category = "Inventory")
FOnInventoryItemEventSignature OnItemRemoved;
/**
* When true (default) inventory replicates only to the owning client (player
* inventory, section 25.9). Set false for shared world containers (loot chests,
* vehicle cargo) so nearby clients can see the contents. Must be set before the
* component registers replication (e.g. in the owning actor's constructor).
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory|Replication")
bool bOwnerOnlyReplication = true;
// --- Container setup ---
/** Registers a container. Assigns a new ContainerId if unset. Server side. */
UFUNCTION(BlueprintCallable, Category = "Inventory")
FGuid AddContainer(FInventoryContainer Container);
/** Registers a container from a DT_Containers template (section 6). Server. */
UFUNCTION(BlueprintCallable, Category = "Inventory")
FGuid AddContainerByTag(FGameplayTag ContainerTag);
/** Creates a nested container owned by an item that has Feature.Container (section 7). */
UFUNCTION(BlueprintCallable, Category = "Inventory")
FGuid AddNestedContainerForItem(FGuid OwnerItemInstanceId, FGameplayTag ContainerTag);
UFUNCTION(BlueprintPure, Category = "Inventory")
bool GetContainer(FGuid ContainerId, FInventoryContainer& OutContainer) const;
UFUNCTION(BlueprintPure, Category = "Inventory")
void GetContainers(TArray<FInventoryContainer>& OutContainers) const { OutContainers = Containers; }
/** First container whose tag matches, or an invalid guid. */
UFUNCTION(BlueprintPure, Category = "Inventory")
FGuid FindContainerByTag(FGameplayTag ContainerTag) const;
// --- Core operations (section 7.1) ---
/** Adds an item, stacking into existing compatible stacks then free slots. Server. */
UFUNCTION(BlueprintCallable, Category = "Inventory")
bool AddItem(UPARAM(ref) FItemInstanceData& Item);
/** Adds an item into a specific container. Server. */
UFUNCTION(BlueprintCallable, Category = "Inventory")
bool AddItemToContainer(UPARAM(ref) FItemInstanceData& Item, FGuid ContainerId);
UFUNCTION(BlueprintCallable, Category = "Inventory")
bool RemoveItem(FGuid InstanceId);
/** Removes up to Amount from a stack; removes the entry when it hits zero. Server. */
UFUNCTION(BlueprintCallable, Category = "Inventory")
bool RemoveQuantity(FGuid InstanceId, int32 Amount);
UFUNCTION(BlueprintCallable, Category = "Inventory")
bool MoveItem(FGuid InstanceId, FGuid FromContainerId, FGuid ToContainerId, int32 TargetSlot);
UFUNCTION(BlueprintCallable, Category = "Inventory")
bool SplitStack(FGuid InstanceId, int32 Amount);
UFUNCTION(BlueprintCallable, Category = "Inventory")
bool MergeStack(FGuid SourceInstanceId, FGuid TargetInstanceId);
// --- Queries ---
UFUNCTION(BlueprintPure, Category = "Inventory")
bool CanAcceptItem(const FItemInstanceData& Item, FGuid ContainerId) const;
UFUNCTION(BlueprintPure, Category = "Inventory")
bool FindItem(FGuid InstanceId, FItemInstanceData& OutItem) const;
UFUNCTION(BlueprintPure, Category = "Inventory")
void GetContainerItems(FGuid ContainerId, TArray<FItemInstanceData>& OutItems) const;
UFUNCTION(BlueprintPure, Category = "Inventory")
void GetAllItems(TArray<FItemInstanceData>& OutItems) const;
UFUNCTION(BlueprintPure, Category = "Inventory")
float GetContainerWeight(FGuid ContainerId) const;
/** Total quantity of a given ItemId across all containers. */
UFUNCTION(BlueprintPure, Category = "Inventory")
int32 GetItemCount(FName ItemId) const;
// --- Internal mutation used by the transaction layer ---
/** Writes a mutated copy back into the list and replicates it. Server. */
bool UpdateItem(const FItemInstanceData& Item);
/** Server: place a soft lock on an item so it cannot be moved/taken (section 20). */
UFUNCTION(BlueprintCallable, Category = "Inventory")
bool SetItemLock(FGuid InstanceId, FGameplayTag Reason, AActor* LockedBy, float ExpireWorldTime);
UFUNCTION(BlueprintCallable, Category = "Inventory")
bool ClearItemLock(FGuid InstanceId);
/** Reads a ServerOnly property kept off the wire (section 22). Server only. */
UFUNCTION(BlueprintPure, Category = "Inventory")
float GetServerOnlyFloat(FGuid InstanceId, FGameplayTag PropertyTag, float Fallback = 0.f) const;
/** Direct, unchecked write of an item into a slot (used by transactions). Server. */
void SetItemInSlot(const FItemInstanceData& Item, FGuid ContainerId, int32 SlotIndex);
/** Server: remove every item (save load / debug clear). */
UFUNCTION(BlueprintCallable, Category = "Inventory")
void ClearAllItems();
/** Server: replace the container set (save load). */
void SetContainers(const TArray<FInventoryContainer>& InContainers);
/** Broadcasts OnInventoryChanged. Called by the FastArray on clients too. */
void NotifyInventoryChanged();
void NotifyItemAdded(const FItemInstanceData& Item);
void NotifyItemRemoved(const FItemInstanceData& Item);
protected:
UPROPERTY(Replicated)
FReplicatedInventoryList ReplicatedItems;
UPROPERTY(Replicated)
TArray<FInventoryContainer> Containers;
/** Default containers spawned in InitializeComponent (designer-configurable). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory|Setup")
TArray<FInventoryContainer> DefaultContainers;
private:
bool HasAuthorityChecked(const TCHAR* Op) const;
FInventoryContainer* GetContainerMutable(const FGuid& ContainerId);
const FInventoryContainer* GetContainerConst(const FGuid& ContainerId) const;
/** Lowest free slot index in a slot-limited container, or INDEX_NONE if full/unlimited-ok. */
int32 FindFreeSlot(const FGuid& ContainerId) const;
/** Grid-aware free slot for a specific item (uses its GridSize); falls back to FindFreeSlot. */
int32 FindFreeSlotForItem(const FGuid& ContainerId, const FItemInstanceData& Item) const;
FIntPoint GetItemGridSize(FName ItemId) const;
/** True if the WxH rectangle at TopLeft is inside the grid and unoccupied (ignoring IgnoreInstance). */
bool IsGridRegionFree(const FInventoryContainer& Container, int32 TopLeft, FIntPoint Size, const FGuid& IgnoreInstance) const;
bool IsSlotOccupied(const FGuid& ContainerId, int32 SlotIndex) const;
/** Tries to top up existing stacks of the same item; reduces Remaining accordingly. */
void StackIntoExisting(const FGuid& ContainerId, FItemInstanceData& Item, int32& Remaining);
/** Moves an item's ServerOnly properties into the server-side side channel (section 22). */
void StripServerOnlyProperties(FItemInstanceData& Item);
/** Server-only properties never replicated to clients, keyed by instance id. */
TMap<FGuid, FItemRuntimeProperties> ServerOnlyProps;
};

View File

@ -0,0 +1,74 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "ItemEnums.h"
#include "InventoryContainer.generated.h"
/**
* Metadata describing one container owned by an inventory (section 7.2).
*
* NOTE (design deviation from the TЗ sketch): the actual item entries do NOT live
* inside the container struct. They live in the component's single
* FReplicatedInventoryList, tagged with this ContainerId. A FastArraySerializer
* only delta-replicates correctly as a direct UPROPERTY, so nesting one inside a
* replicated TArray<FInventoryContainer> would silently fall back to full-array
* replication. Keeping containers as lightweight metadata fixes that (section 32).
*/
USTRUCT(BlueprintType)
struct ITEMINVENTORY_API FInventoryContainer
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Container")
FGuid ContainerId;
/** Logical identity, e.g. Container.Backpack / Container.Hotbar / Container.Vehicle.Cargo. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Container")
FGameplayTag ContainerTag;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Container")
EInventoryContainerType Type = EInventoryContainerType::SlotList;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Container")
int32 Width = 0;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Container")
int32 Height = 0;
/** Used by SlotList/Equipment/Hotbar; for Grid types capacity is Width*Height. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Container")
int32 MaxSlots = 0;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Container")
float MaxWeight = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Container")
FGameplayTagContainer AcceptedItemTags;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Container")
FGameplayTagContainer BlockedItemTags;
/** Set when this container lives inside another item (a backpack/pouch item) - section 7. */
UPROPERTY(BlueprintReadOnly, Category = "Container")
FGuid OwnerItemInstanceId;
FInventoryContainer() = default;
bool IsNested() const { return OwnerItemInstanceId.IsValid(); }
/** Number of addressable slots in this container (0 = unlimited weight-gated list). */
int32 GetSlotCount() const
{
if (Type == EInventoryContainerType::Grid)
{
return FMath::Max(0, Width) * FMath::Max(0, Height);
}
return FMath::Max(0, MaxSlots);
}
bool HasSlotLimit() const { return GetSlotCount() > 0; }
bool HasWeightLimit() const { return MaxWeight > 0.f; }
};

View File

@ -0,0 +1,38 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "GameplayTagContainer.h"
#include "ItemCraftingLibrary.generated.h"
class UInventoryComponent;
/**
* Crafting + loot helpers (section 6). All server-authoritative: crafting consumes
* inputs and produces the output through the inventory's validated operations;
* loot rolls fresh instances from a loot table into a container.
*/
UCLASS()
class ITEMINVENTORY_API UItemCraftingLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/** True if Inventory holds every ingredient (and the station requirement is met). */
UFUNCTION(BlueprintCallable, Category = "Item|Crafting", meta = (WorldContext = "WorldContext"))
static bool CanCraft(const UObject* WorldContext, UInventoryComponent* Inventory, FName RecipeId, FGameplayTag AvailableStation);
/** Server: consume the recipe inputs and add its output. Returns true on success. */
UFUNCTION(BlueprintCallable, Category = "Item|Crafting", meta = (WorldContext = "WorldContext"))
static bool Craft(const UObject* WorldContext, UInventoryComponent* Inventory, FName RecipeId, FGameplayTag AvailableStation);
/** Server: roll a loot table and add the results into an inventory. */
UFUNCTION(BlueprintCallable, Category = "Item|Loot", meta = (WorldContext = "WorldContext"))
static void RollLootIntoInventory(const UObject* WorldContext, UInventoryComponent* Inventory, FName TableId, int32 RollCount);
private:
/** Removes Count of ItemId across stacks. Returns true if the full amount was removed. */
static bool ConsumeItem(UInventoryComponent* Inventory, FName ItemId, int32 Count);
};

View File

@ -0,0 +1,56 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "ItemInstanceData.h"
#include "ItemFeatureLibrary.generated.h"
class UInventoryComponent;
/**
* Feature processing (section 21). Everything is LAZY: nothing ticks per item.
* Call these on events - container open, save load, interaction, world-time pulse.
* State (freshness, last-update time, charge, fuel) lives in the item's runtime
* properties so it travels with the instance.
*/
UCLASS()
class ITEMINVENTORY_API UItemFeatureLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/**
* Recomputes time-based features (rotting) for one item up to "now".
* Returns true if the item changed. If the item should transform (e.g. raw_meat
* -> rotten_meat) OutTransformItemId is set to the replacement id.
*/
UFUNCTION(BlueprintCallable, Category = "Item|Features", meta = (WorldContext = "WorldContext"))
static bool UpdateLazyFeatures(const UObject* WorldContext, UPARAM(ref) FItemInstanceData& Item, FName& OutTransformItemId);
/** Server: run lazy updates across a whole inventory, applying any transforms. */
UFUNCTION(BlueprintCallable, Category = "Item|Features", meta = (WorldContext = "WorldContext"))
static void UpdateInventoryFeatures(const UObject* WorldContext, UInventoryComponent* Inventory);
// --- Direct feature operations (debug tools, gameplay) ---
UFUNCTION(BlueprintCallable, Category = "Item|Features", meta = (WorldContext = "WorldContext"))
static void ForceRot(const UObject* WorldContext, UPARAM(ref) FItemInstanceData& Item, FName& OutTransformItemId);
UFUNCTION(BlueprintCallable, Category = "Item|Features")
static void BreakItem(UPARAM(ref) FItemInstanceData& Item);
UFUNCTION(BlueprintCallable, Category = "Item|Features")
static void DrainBattery(UPARAM(ref) FItemInstanceData& Item, float DrainPerSecond, float Seconds);
UFUNCTION(BlueprintCallable, Category = "Item|Features")
static float AddFuel(UPARAM(ref) FItemInstanceData& Item, float Amount, float MaxFuel);
/** Moves up to Amount of fuel from one item to another, respecting ToMaxFuel. Returns moved. */
UFUNCTION(BlueprintCallable, Category = "Item|Features")
static float TransferFuel(UPARAM(ref) FItemInstanceData& From, UPARAM(ref) FItemInstanceData& To, float Amount, float ToMaxFuel);
UFUNCTION(BlueprintCallable, Category = "Item|Features")
static void SetWetness(UPARAM(ref) FItemInstanceData& Item, float Wetness);
};

View File

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

View File

@ -0,0 +1,34 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "ItemInstanceData.h"
#include "InventoryContainer.h"
#include "ItemSaveGame.generated.h"
/** Saved snapshot of one inventory (section 26). Only ids/state are kept. */
USTRUCT(BlueprintType)
struct ITEMINVENTORY_API FInventorySaveData
{
GENERATED_BODY()
UPROPERTY(SaveGame)
TArray<FItemInstanceData> Items;
UPROPERTY(SaveGame)
TArray<FInventoryContainer> Containers;
};
/** SaveGame object holding any number of named inventory snapshots. */
UCLASS()
class ITEMINVENTORY_API UItemSaveGame : public USaveGame
{
GENERATED_BODY()
public:
/** Keyed by a stable inventory id chosen by the game (e.g. "Player0", a chest guid). */
UPROPERTY(SaveGame)
TMap<FName, FInventorySaveData> Inventories;
};

View File

@ -0,0 +1,38 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "ItemSaveGame.h"
#include "ItemSaveLibrary.generated.h"
class UInventoryComponent;
/**
* Save / load of inventories with redirect + version migration (section 26).
* Stores only ItemId/InstanceId/Quantity/RuntimeProperties/StateTags/container/slot
* - never icons, meshes, names or actor pointers (section 26 "do not save").
*/
UCLASS()
class ITEMINVENTORY_API UItemSaveLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/** Snapshots an inventory's items + containers (drops transient locks). */
UFUNCTION(BlueprintCallable, Category = "Item|Save")
static FInventorySaveData CaptureInventory(UInventoryComponent* Inventory);
/** Server: rebuilds an inventory from a snapshot, migrating each item. */
UFUNCTION(BlueprintCallable, Category = "Item|Save", meta = (WorldContext = "WorldContext"))
static void RestoreInventory(const UObject* WorldContext, UInventoryComponent* Inventory, const FInventorySaveData& Data);
/** Writes one inventory under Key into a save slot (loads/creates the slot file). */
UFUNCTION(BlueprintCallable, Category = "Item|Save")
static bool SaveInventoryToSlot(UInventoryComponent* Inventory, const FString& SlotName, FName Key);
/** Server: loads one inventory under Key from a save slot. */
UFUNCTION(BlueprintCallable, Category = "Item|Save", meta = (WorldContext = "WorldContext"))
static bool LoadInventoryFromSlot(const UObject* WorldContext, UInventoryComponent* Inventory, const FString& SlotName, FName Key);
};

View File

@ -0,0 +1,101 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "ItemInstanceData.h"
#include "ItemTransaction.generated.h"
class UInventoryComponent;
/** Kind of mutation a transaction step performs (section 10). */
UENUM(BlueprintType)
enum class EItemTransactionStepType : uint8
{
AddItem,
RemoveItem,
UpdateItem,
MoveItem
};
/** One atomic step. Carries enough to apply AND to roll back. */
USTRUCT(BlueprintType)
struct ITEMINVENTORY_API FItemTransactionStep
{
GENERATED_BODY()
UPROPERTY()
EItemTransactionStepType Type = EItemTransactionStepType::AddItem;
UPROPERTY()
TWeakObjectPtr<UInventoryComponent> Inventory;
/** Item payload for AddItem / UpdateItem. */
UPROPERTY()
FItemInstanceData Item;
UPROPERTY()
FGuid InstanceId;
UPROPERTY()
FGuid ToContainer;
UPROPERTY()
int32 ToSlot = INDEX_NONE;
// --- runtime (rollback bookkeeping) ---
UPROPERTY()
bool bApplied = false;
UPROPERTY()
FItemInstanceData UndoSnapshot;
UPROPERTY()
FGuid UndoFromContainer;
UPROPERTY()
int32 UndoFromSlot = INDEX_NONE;
};
/**
* Atomic multi-step item operation (section 10). Build steps, then Apply():
* if any step fails, every applied step is rolled back so the inventories are
* left untouched. The single correct way to do multi-inventory moves.
*
* Server-only. Operates through UInventoryComponent's authoritative methods.
*/
USTRUCT(BlueprintType)
struct ITEMINVENTORY_API FItemTransaction
{
GENERATED_BODY()
UPROPERTY()
FGuid TransactionId;
UPROPERTY()
FGameplayTag TransactionType;
UPROPERTY()
TWeakObjectPtr<AActor> Instigator;
UPROPERTY()
TArray<FItemTransactionStep> Steps;
FItemTransaction() { TransactionId = FGuid::NewGuid(); }
// --- Builders ---
FItemTransaction& Add(UInventoryComponent* Inventory, const FItemInstanceData& Item);
FItemTransaction& Remove(UInventoryComponent* Inventory, const FGuid& InstanceId);
FItemTransaction& Update(UInventoryComponent* Inventory, const FItemInstanceData& Item);
FItemTransaction& Move(UInventoryComponent* Inventory, const FGuid& InstanceId, const FGuid& ToContainerId, int32 ToSlotIndex);
/** Non-mutating feasibility check of every step. */
bool Validate() const;
/** Applies all steps; on any failure rolls back and returns false. */
bool Apply();
/** Undoes every applied step in reverse order. */
void Rollback();
};

View File

@ -0,0 +1,84 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Net/Serialization/FastArraySerializer.h"
#include "ItemInstanceData.h"
#include "ReplicatedInventoryList.generated.h"
class UInventoryComponent;
/**
* One replicated item slot (section 7.3). The item carries its own ContainerId /
* SlotIndex inside FItemInstanceData, but they are mirrored here so the FastArray
* delta only needs the entry, and so clients can route OnRep callbacks per slot.
*/
USTRUCT()
struct ITEMINVENTORY_API FRepInventoryEntry : public FFastArraySerializerItem
{
GENERATED_BODY()
UPROPERTY()
FGuid ContainerId;
UPROPERTY()
int32 SlotIndex = INDEX_NONE;
UPROPERTY()
FItemInstanceData Item;
FRepInventoryEntry() = default;
FRepInventoryEntry(const FGuid& InContainer, int32 InSlot, const FItemInstanceData& InItem)
: ContainerId(InContainer), SlotIndex(InSlot), Item(InItem) {}
};
/**
* FastArraySerializer carrying every item the inventory owns across all containers
* (section 7.3 / 32). One list per component delta-replicates only changed entries.
*/
USTRUCT()
struct ITEMINVENTORY_API FReplicatedInventoryList : public FFastArraySerializer
{
GENERATED_BODY()
UPROPERTY()
TArray<FRepInventoryEntry> Entries;
/** Not replicated - set by the owning component so callbacks can broadcast. */
UPROPERTY(NotReplicated)
TObjectPtr<UInventoryComponent> OwnerComponent = nullptr;
bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms)
{
return FFastArraySerializer::FastArrayDeltaSerialize<FRepInventoryEntry, FReplicatedInventoryList>(Entries, DeltaParms, *this);
}
//~ FFastArraySerializer client callbacks
void PostReplicatedAdd(const TArrayView<int32>& AddedIndices, int32 FinalSize);
void PostReplicatedChange(const TArrayView<int32>& ChangedIndices, int32 FinalSize);
void PreReplicatedRemove(const TArrayView<int32>& RemovedIndices, int32 FinalSize);
// --- Server-side mutation helpers (mark dirty for delta replication) ---
/** Adds or replaces the entry occupying (Container, Slot). Returns the live entry. */
FRepInventoryEntry& AddOrUpdate(const FGuid& Container, int32 Slot, const FItemInstanceData& Item);
/** Removes the entry with the given instance id. Returns true if one was removed. */
bool RemoveByInstanceId(const FGuid& InstanceId);
/** Marks an existing entry dirty after its Item was mutated in place. */
void MarkEntryDirtyByInstanceId(const FGuid& InstanceId);
FRepInventoryEntry* FindByInstanceId(const FGuid& InstanceId);
const FRepInventoryEntry* FindByInstanceId(const FGuid& InstanceId) const;
};
template<>
struct TStructOpsTypeTraits<FReplicatedInventoryList> : public TStructOpsTypeTraitsBase2<FReplicatedInventoryList>
{
enum
{
WithNetDeltaSerializer = true
};
};