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

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
# Unreal Engine plugin build artifacts
Binaries/
Intermediate/
Saved/
DerivedDataCache/
# IDE / OS
.vs/
*.VC.db
*.opensdf
*.sdf
*.sln
*.suo
*.user
.idea/
*.pdb
Thumbs.db
.DS_Store

View File

@ -0,0 +1,10 @@
Name,ItemId,Feature,Param,Value
raw_meat_decay,raw_meat,Feature.Rotting,DecayRatePerHour,0.08
raw_meat_rotten,raw_meat,Feature.Rotting,RottenItemId,rotten_meat
raw_meat_smell,raw_meat,Feature.Smell,SmellWhenRotten,1.0
flashlight_drain,flashlight,Feature.Battery,DrainPerSecond,0.003
flashlight_break,flashlight,Feature.Durability,BreakOnImpactVelocity,800
fuel_canister_max,fuel_canister,Feature.Fuel,MaxFuel,20
fuel_canister_flammable,fuel_canister,Feature.Flammable,ExplosionRadius,500
glass_bottle_noise,glass_bottle,Feature.NoiseOnDrop,Radius,1200
car_battery_carry,car_battery,Feature.Installable,MoveSpeedMultiplier,0.6
1 Name ItemId Feature Param Value
2 raw_meat_decay raw_meat Feature.Rotting DecayRatePerHour 0.08
3 raw_meat_rotten raw_meat Feature.Rotting RottenItemId rotten_meat
4 raw_meat_smell raw_meat Feature.Smell SmellWhenRotten 1.0
5 flashlight_drain flashlight Feature.Battery DrainPerSecond 0.003
6 flashlight_break flashlight Feature.Durability BreakOnImpactVelocity 800
7 fuel_canister_max fuel_canister Feature.Fuel MaxFuel 20
8 fuel_canister_flammable fuel_canister Feature.Flammable ExplosionRadius 500
9 glass_bottle_noise glass_bottle Feature.NoiseOnDrop Radius 1200
10 car_battery_carry car_battery Feature.Installable MoveSpeedMultiplier 0.6

View File

@ -0,0 +1,6 @@
Name,ItemId,Context,ActionTag,InteractionMode,Priority,bRequiresHold,HoldDuration,ActionDuration,Requirement,Result
car_battery_install,car_battery,Held+Vehicle,Action.Install,InstallIntoWorldSlot,1000,false,0,3,,InstallIntoSlot
car_battery_install_gen,car_battery,Held+Generator,Action.Install,InstallIntoWorldSlot,1000,false,0,3,,InstallIntoSlot
fuel_pour,fuel_canister,Held+Generator,Action.UseHeldOnTarget,UseHeldOnTarget,1000,true,0.3,5,FuelAmount>0,TransferFuel
flashlight_equip,flashlight,Inventory,Action.Equip,EquipFromInventory,800,false,0,0,BatteryCharge>0,EquipItem
raw_meat_bait,raw_meat,Held+Trap,Action.Install,InstallIntoWorldSlot,900,false,0,2,,InstallIntoSlot
1 Name ItemId Context ActionTag InteractionMode Priority bRequiresHold HoldDuration ActionDuration Requirement Result
2 car_battery_install car_battery Held+Vehicle Action.Install InstallIntoWorldSlot 1000 false 0 3 InstallIntoSlot
3 car_battery_install_gen car_battery Held+Generator Action.Install InstallIntoWorldSlot 1000 false 0 3 InstallIntoSlot
4 fuel_pour fuel_canister Held+Generator Action.UseHeldOnTarget UseHeldOnTarget 1000 true 0.3 5 FuelAmount>0 TransferFuel
5 flashlight_equip flashlight Inventory Action.Equip EquipFromInventory 800 false 0 0 BatteryCharge>0 EquipItem
6 raw_meat_bait raw_meat Held+Trap Action.Install InstallIntoWorldSlot 900 false 0 2 InstallIntoSlot

View File

@ -0,0 +1,8 @@
Name,ItemId,PropertyTag,DefaultValue,Min,Max,ReplicationPolicy,bSave
raw_meat_freshness,raw_meat,Property.Freshness,1.0,0,1,OwnerOnly,true
raw_meat_contamination,raw_meat,Property.Contamination,0,0,1,OwnerOnly,true
flashlight_battery,flashlight,Property.BatteryCharge,1.0,0,1,OwnerOnly,true
axe_durability,axe,Property.Durability,1.0,0,1,OwnerOnly,true
fuel_canister_fuel,fuel_canister,Property.FuelAmount,10.0,0,20,OwnerOnly,true
car_battery_charge,car_battery,Property.Charge,0.65,0,1,OwnerOnly,true
generator_fuel,generator_small,Property.FuelAmount,0,0,40,Everyone,true
1 Name ItemId PropertyTag DefaultValue Min Max ReplicationPolicy bSave
2 raw_meat_freshness raw_meat Property.Freshness 1.0 0 1 OwnerOnly true
3 raw_meat_contamination raw_meat Property.Contamination 0 0 1 OwnerOnly true
4 flashlight_battery flashlight Property.BatteryCharge 1.0 0 1 OwnerOnly true
5 axe_durability axe Property.Durability 1.0 0 1 OwnerOnly true
6 fuel_canister_fuel fuel_canister Property.FuelAmount 10.0 0 20 OwnerOnly true
7 car_battery_charge car_battery Property.Charge 0.65 0 1 OwnerOnly true
8 generator_fuel generator_small Property.FuelAmount 0 0 40 Everyone true

View File

@ -0,0 +1,3 @@
Name,OldItemId,NewItemId
redirect_medkit,medkit,medkit_small
redirect_flashlight,old_flashlight,flashlight
1 Name OldItemId NewItemId
2 redirect_medkit medkit medkit_small
3 redirect_flashlight old_flashlight flashlight

View File

@ -0,0 +1,11 @@
Name,ItemId,DisplayName,Description,ItemType,ItemTags,Icon,WorldMesh,PickupActorClass,EquippedActorClass,MaxStack,Weight,GridSize,bCanStoreInInventory,bCanInstantPickup,bCanWorldCarry,bCanEquip,bCanPlace,bCanInstallIntoWorldSlot,bCanDrop,bIsQuestCritical,DefaultInteraction,AlternativeInteraction,AllowedEquipmentSlots,CompatibleWorldSlots,ItemFeatures,PlacementRules
ammo_9mm,ammo_9mm,9mm Ammo,,Item.Ammo.9mm,,,,,,60,0.01,"(X=1,Y=1)",true,true,false,false,false,false,true,false,Action.Pickup.Instant,,,,,
medkit_small,medkit_small,Small Medkit,,Item.Medical.Medkit,,,,,,5,0.5,"(X=1,Y=1)",true,true,false,false,false,false,true,false,Action.Pickup.Instant,,,,,
flashlight,flashlight,Flashlight,,Item.Tool.Flashlight,,,,,,1,0.4,"(X=1,Y=2)",true,true,true,true,false,false,true,false,Action.Pickup.Instant,,,,,
raw_meat,raw_meat,Raw Meat,,Item.Food.Meat,,,,,,10,0.3,"(X=1,Y=1)",true,true,false,false,false,false,true,false,Action.Pickup.Instant,,,,,
rotten_meat,rotten_meat,Rotten Meat,,Item.Food.Meat,,,,,,10,0.3,"(X=1,Y=1)",true,true,false,false,false,false,true,false,Action.Pickup.Instant,,,,,
fuel_canister,fuel_canister,Fuel Canister,,Item.Resource.Fuel,,,,,,1,5,"(X=2,Y=2)",true,true,true,false,false,false,true,false,Action.Pickup.Instant,,,,,
car_battery,car_battery,Car Battery,,Item.VehiclePart.Battery,,,,,,1,12,"(X=2,Y=2)",false,false,true,false,false,true,true,false,Action.Carry,,,"(GameplayTags=((TagName=""Vehicle.Socket.Battery""),(TagName=""Generator.Socket.Battery"")))",,
generator_small,generator_small,Portable Generator,,Item.Device.Generator,,,,,,1,30,"(X=3,Y=3)",false,false,true,false,true,false,true,false,Action.Carry,,,,,
trap_small,trap_small,Small Trap,,Item.Trap,,,,,,3,1.0,"(X=2,Y=1)",true,true,false,false,true,false,true,false,Action.Pickup.Instant,,,,,
key_bunker_a,key_bunker_a,Bunker Key A,,Item.Key,,,,,,1,0.05,"(X=1,Y=1)",true,true,false,false,false,false,false,true,Action.Pickup.Instant,,,,,
1 Name ItemId DisplayName Description ItemType ItemTags Icon WorldMesh PickupActorClass EquippedActorClass MaxStack Weight GridSize bCanStoreInInventory bCanInstantPickup bCanWorldCarry bCanEquip bCanPlace bCanInstallIntoWorldSlot bCanDrop bIsQuestCritical DefaultInteraction AlternativeInteraction AllowedEquipmentSlots CompatibleWorldSlots ItemFeatures PlacementRules
2 ammo_9mm ammo_9mm 9mm Ammo Item.Ammo.9mm 60 0.01 (X=1,Y=1) true true false false false false true false Action.Pickup.Instant
3 medkit_small medkit_small Small Medkit Item.Medical.Medkit 5 0.5 (X=1,Y=1) true true false false false false true false Action.Pickup.Instant
4 flashlight flashlight Flashlight Item.Tool.Flashlight 1 0.4 (X=1,Y=2) true true true true false false true false Action.Pickup.Instant
5 raw_meat raw_meat Raw Meat Item.Food.Meat 10 0.3 (X=1,Y=1) true true false false false false true false Action.Pickup.Instant
6 rotten_meat rotten_meat Rotten Meat Item.Food.Meat 10 0.3 (X=1,Y=1) true true false false false false true false Action.Pickup.Instant
7 fuel_canister fuel_canister Fuel Canister Item.Resource.Fuel 1 5 (X=2,Y=2) true true true false false false true false Action.Pickup.Instant
8 car_battery car_battery Car Battery Item.VehiclePart.Battery 1 12 (X=2,Y=2) false false true false false true true false Action.Carry (GameplayTags=((TagName="Vehicle.Socket.Battery"),(TagName="Generator.Socket.Battery")))
9 generator_small generator_small Portable Generator Item.Device.Generator 1 30 (X=3,Y=3) false false true false true false true false Action.Carry
10 trap_small trap_small Small Trap Item.Trap 3 1.0 (X=2,Y=1) true true false false true false true false Action.Pickup.Instant
11 key_bunker_a key_bunker_a Bunker Key A Item.Key 1 0.05 (X=1,Y=1) true true false false false false false true Action.Pickup.Instant

65
ItemEcosystem.uplugin Normal file
View File

@ -0,0 +1,65 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "0.1.0",
"FriendlyName": "Item Interaction Ecosystem",
"Description": "Universal, server-authoritative, data-driven item / inventory / interaction / placement ecosystem framework for UE5.",
"Category": "Gameplay",
"CreatedBy": "ExByte Studios",
"CreatedByURL": "",
"DocsURL": "",
"MarketplaceURL": "",
"SupportURL": "",
"CanContainContent": true,
"IsBetaVersion": true,
"IsExperimentalVersion": false,
"Installed": false,
"Modules": [
{
"Name": "ItemCore",
"Type": "Runtime",
"LoadingPhase": "PreDefault"
},
{
"Name": "ItemDatabase",
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "ItemInventory",
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "ItemInteraction",
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "ItemWorld",
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "ItemPlacement",
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "ItemDebug",
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "ItemDeveloper",
"Type": "Editor",
"LoadingPhase": "PostEngineInit"
}
],
"Plugins": [
{
"Name": "EnhancedInput",
"Enabled": true
}
]
}

138
README.md Normal file
View File

@ -0,0 +1,138 @@
# Item Interaction Ecosystem (UE5 Plugin)
Universal, server-authoritative, data-driven framework for items, inventory,
interaction, carrying, equipment, world-slot installation, placement and item
features. Built for co-op horror / survival / extraction / puzzle projects.
Tested compiling against **Unreal Engine 5.7**.
> Status: all C++ runtime + editor modules implemented and compiling (spec stages
> 111). Binary content (UMG widgets, the test map, and the DataTable *assets*
> themselves) must be authored in-editor — see "Content to author" below. Example
> data is provided as importable CSV under `Docs/ExampleData/`.
---
## Modules
| Module | Type | Responsibility |
|---|---|---|
| `ItemCore` | Runtime | Base structs (`FItemInstanceData`, `FItemRuntimeProperties`, lock data), enums, native gameplay tags, logs |
| `ItemDatabase` | Runtime | `FItemDefinitionRow` + aux rows, `UItemDatabaseSubsystem` (loading, lookup, redirects, validation), Project Settings |
| `ItemInventory` | Runtime | `UInventoryComponent` (FastArray replication), containers, stacking, `UItemFeatureLibrary` |
| `ItemInteraction` | Runtime | Interactable/trace components, `UInteractionResolver`, `UItemInteractionComponent` (server requests + validation) |
| `ItemWorld` | Runtime | `APickupItemActor`, carry (`UHeldItemComponent`), equipment, world slots, `UWorldInteractionComponent`, storage actor |
| `ItemPlacement` | Runtime | `UPlacementPreviewComponent` (ghost preview, rules, server confirm) |
| `ItemDebug` | Runtime | `ItemEco.*` console commands |
| `ItemDeveloper` | Editor | Tools menu → **Validate Item Data** |
Design principle (section 3): the **client never mutates item state** — it sends
requests; the server re-validates and replicates results.
---
## Setup
1. **Enable the plugin** (already referenced by `InventorySystem.uproject`). Build the
`InventorySystemEditor` target.
2. **Create the DataTables** (Content Browser → Miscellaneous → Data Table):
- `DT_Items` — row struct `FItemDefinitionRow`
- `DT_ItemProperties``FItemPropertyRow`
- `DT_ItemFeatures``FItemFeatureRow`
- `DT_ItemInteractions``FItemInteractionRow`
- `DT_ItemRedirects``FItemRedirectRow`
Import the CSVs in `Docs/ExampleData/` as a quick start (the flat tables import
cleanly; in `DT_Items` the gameplay-tag *container* columns are easiest to finish
in-editor).
3. **Project Settings → Game → Item Ecosystem** — assign the five tables.
4. **Register the gameplay tags** the data uses (Project Settings → GameplayTags), or
rely on the native ones shipped in `ItemNativeTags.h` (Property.*, Feature.*,
Action.*, Lock.*). Item types / world sockets (e.g. `Vehicle.Socket.Battery`) are
project tags you add.
### Player pawn components
Add to your pawn:
- `UInventoryComponent` — set `DefaultContainers` (e.g. a Backpack SlotList + a Hotbar).
- `UWorldInteractionComponent` — the executor (use this, not the base, to get carry /
drop / equip / install).
- `UHeldItemComponent` — for two-handed carry; set `CarrySocket`.
- `UEquipmentComponent` — for equip visuals; map `SlotSockets`.
- `UInteractionTraceComponent` — local aim trace (drives the prompt).
- `UPlacementPreviewComponent` — if you support placement.
### Input (Enhanced Input, section 24)
Bind these to call into the components:
| Key | Call |
|---|---|
| `E` | `Resolver.GetPrimaryInteraction(pawn, trace.CurrentTarget)``WorldInteraction.RequestInteraction(option)` |
| `G` | `HeldItemComponent.RequestDrop()` |
| LMB | `PlacementPreview.ConfirmPlacement()` (while placing) |
| RMB | `PlacementPreview.CancelPlacement()` |
| Wheel | `PlacementPreview.RotatePreview(±10)` |
| `Tab` | open inventory UI |
---
## Console commands (ItemDebug)
```
ItemEco.Give <ItemId> [Qty] Give to the local player inventory
ItemEco.Spawn <ItemId> [Qty] Spawn a world pickup in front of the player
ItemEco.Clear Clear the player inventory
ItemEco.Dump Log all inventory entries
ItemEco.Validate Run database validation
ItemEco.Rot Force-rot the first inventory item
ItemEco.Break Break the first inventory item
```
---
## Demo scenarios → code (section 31)
- **Ammo pickup** — look at `ammo_9mm` pickup → resolver builds `[E] Pick up 9mm Ammo x12`
(capability option) → `WorldInteraction``ExecutePickup` adds to inventory, destroys actor.
- **Loot crate** — `AStorageContainerActor` (shared inventory) → `[E] Open` (local UI event
`OnOpenContainer`) → drag → `RequestMoveItem(storage, …)` → validated transfer.
- **Car battery** — carry from world (`ServerHoldWorldItem`, move-speed penalty) → look at
vehicle with `UWorldItemSlotComponent(Vehicle.Socket.Battery)` → DT_ItemInteractions row
`Held+Vehicle → InstallIntoWorldSlot``ExecuteInstall` hands the carried actor to the slot.
- **Fuel + generator** — `Held+Generator → UseHeldOnTarget` (hold E). Wire the transfer with
`UItemFeatureLibrary::TransferFuel` in a custom `UInteractionAction` or extend
`WorldInteraction` (`UseHeldOnTarget` currently routes to action classes).
- **Flashlight** — equip from inventory (`UEquipmentComponent::ServerEquip`), item stays in
inventory, `EquippedActorClass` spawns; drain with `UItemFeatureLibrary::DrainBattery`.
- **Placement trap** — `PlacementPreview.StartPlacement(id)` → ghost + `ValidatePlacement`
red/green → confirm → server re-validates → spawns placed actor, removes from inventory.
- **Raw meat rotting** — `UItemFeatureLibrary::UpdateInventoryFeatures` (call on container
open / save load / world-time pulse). Lazy: nothing ticks per item. At freshness 0 it
transforms `raw_meat → rotten_meat`.
---
## Content to author in-editor (binary assets, not creatable from source)
- DataTable assets (import the CSVs above).
- UMG widgets (section 23): `WBP_InventoryRoot`, `WBP_InteractionPrompt`,
`WBP_LootWindow`, `WBP_ActionProgress`, `WBP_PlacementHint`, etc. Bind them to the
components' delegates (`OnInventoryChanged`, `OnTargetChanged`, `OnOpenContainer`,
`OnPlacementStateChanged`) — UI only displays and sends requests (section 25).
- `InventoryInteractionTestMap` (section 30) with pickups, a vehicle (battery/fuel
slots), a generator, a storage chest, a placeable trap surface.
- Skeletal-mesh sockets named to match `CarrySocket` / equipment `SlotSockets` /
world-slot `AttachSocketName`.
---
## Build / validate
```
"<UE>\Engine\Build\BatchFiles\Build.bat" InventorySystemEditor Win64 Development ^
-Project="<path>\InventorySystem.uproject" -WaitMutex
```
Then in-editor: Tools → Item Ecosystem → **Validate Item Data**, or `ItemEco.Validate`
in PIE.

View File

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

View File

@ -0,0 +1,22 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemCoreModule.h"
#include "ItemCoreLog.h"
DEFINE_LOG_CATEGORY(LogItemCore);
#define LOCTEXT_NAMESPACE "FItemCoreModule"
void FItemCoreModule::StartupModule()
{
UE_LOG(LogItemCore, Log, TEXT("ItemCore module started."));
}
void FItemCoreModule::ShutdownModule()
{
UE_LOG(LogItemCore, Log, TEXT("ItemCore module shut down."));
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FItemCoreModule, ItemCore)

View File

@ -0,0 +1,77 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemNativeTags.h"
namespace ItemEcosystemTags
{
// --- Runtime property tags ---
UE_DEFINE_GAMEPLAY_TAG(Property_Durability, "Property.Durability");
UE_DEFINE_GAMEPLAY_TAG(Property_Freshness, "Property.Freshness");
UE_DEFINE_GAMEPLAY_TAG(Property_RotProgress, "Property.RotProgress");
UE_DEFINE_GAMEPLAY_TAG(Property_Temperature, "Property.Temperature");
UE_DEFINE_GAMEPLAY_TAG(Property_Wetness, "Property.Wetness");
UE_DEFINE_GAMEPLAY_TAG(Property_FuelAmount, "Property.FuelAmount");
UE_DEFINE_GAMEPLAY_TAG(Property_MaxFuel, "Property.MaxFuel");
UE_DEFINE_GAMEPLAY_TAG(Property_BatteryCharge, "Property.BatteryCharge");
UE_DEFINE_GAMEPLAY_TAG(Property_Contamination, "Property.Contamination");
UE_DEFINE_GAMEPLAY_TAG(Property_NoiseLevel, "Property.NoiseLevel");
UE_DEFINE_GAMEPLAY_TAG(Property_SmellIntensity,"Property.SmellIntensity");
UE_DEFINE_GAMEPLAY_TAG(Property_BloodLevel, "Property.BloodLevel");
UE_DEFINE_GAMEPLAY_TAG(Property_Radiation, "Property.Radiation");
UE_DEFINE_GAMEPLAY_TAG(Property_Poison, "Property.Poison");
UE_DEFINE_GAMEPLAY_TAG(Property_CookedLevel, "Property.CookedLevel");
UE_DEFINE_GAMEPLAY_TAG(Property_BurnLevel, "Property.BurnLevel");
UE_DEFINE_GAMEPLAY_TAG(Property_Quality, "Property.Quality");
UE_DEFINE_GAMEPLAY_TAG(Property_Charge, "Property.Charge");
UE_DEFINE_GAMEPLAY_TAG(Property_LastUpdateTime,"Property.LastUpdateTime");
// --- State tags ---
UE_DEFINE_GAMEPLAY_TAG(State_Broken, "Item.State.Broken");
UE_DEFINE_GAMEPLAY_TAG(State_Rotten, "Item.State.Rotten");
UE_DEFINE_GAMEPLAY_TAG(State_Wet, "Item.State.Wet");
UE_DEFINE_GAMEPLAY_TAG(State_Contaminated, "Item.State.Contaminated");
UE_DEFINE_GAMEPLAY_TAG(State_Empty, "Item.State.Empty");
// --- Lock reasons ---
UE_DEFINE_GAMEPLAY_TAG(Lock_Carrying, "Lock.Carrying");
UE_DEFINE_GAMEPLAY_TAG(Lock_Using, "Lock.Using");
UE_DEFINE_GAMEPLAY_TAG(Lock_Transferring, "Lock.Transferring");
UE_DEFINE_GAMEPLAY_TAG(Lock_Crafting, "Lock.Crafting");
UE_DEFINE_GAMEPLAY_TAG(Lock_Installing, "Lock.Installing");
UE_DEFINE_GAMEPLAY_TAG(Lock_Replicating, "Lock.Replicating");
UE_DEFINE_GAMEPLAY_TAG(Lock_Placement, "Lock.Placement");
// --- Action verbs ---
UE_DEFINE_GAMEPLAY_TAG(Action_Pickup_Instant, "Action.Pickup.Instant");
UE_DEFINE_GAMEPLAY_TAG(Action_Pickup, "Action.Pickup");
UE_DEFINE_GAMEPLAY_TAG(Action_Open, "Action.Open");
UE_DEFINE_GAMEPLAY_TAG(Action_Carry, "Action.Carry");
UE_DEFINE_GAMEPLAY_TAG(Action_Drop, "Action.Drop");
UE_DEFINE_GAMEPLAY_TAG(Action_Equip, "Action.Equip");
UE_DEFINE_GAMEPLAY_TAG(Action_Inspect, "Action.Inspect");
UE_DEFINE_GAMEPLAY_TAG(Action_Use, "Action.Use");
UE_DEFINE_GAMEPLAY_TAG(Action_Install, "Action.Install");
UE_DEFINE_GAMEPLAY_TAG(Action_Remove, "Action.Remove");
UE_DEFINE_GAMEPLAY_TAG(Action_Place, "Action.Place");
UE_DEFINE_GAMEPLAY_TAG(Action_UseHeldOnTarget, "Action.UseHeldOnTarget");
// --- Feature tags ---
UE_DEFINE_GAMEPLAY_TAG(Feature_Consumable, "Feature.Consumable");
UE_DEFINE_GAMEPLAY_TAG(Feature_Rotting, "Feature.Rotting");
UE_DEFINE_GAMEPLAY_TAG(Feature_Durability, "Feature.Durability");
UE_DEFINE_GAMEPLAY_TAG(Feature_Battery, "Feature.Battery");
UE_DEFINE_GAMEPLAY_TAG(Feature_Fuel, "Feature.Fuel");
UE_DEFINE_GAMEPLAY_TAG(Feature_Wetness, "Feature.Wetness");
UE_DEFINE_GAMEPLAY_TAG(Feature_Temperature, "Feature.Temperature");
UE_DEFINE_GAMEPLAY_TAG(Feature_NoiseOnDrop, "Feature.NoiseOnDrop");
UE_DEFINE_GAMEPLAY_TAG(Feature_Smell, "Feature.Smell");
UE_DEFINE_GAMEPLAY_TAG(Feature_Container, "Feature.Container");
UE_DEFINE_GAMEPLAY_TAG(Feature_Equippable, "Feature.Equippable");
UE_DEFINE_GAMEPLAY_TAG(Feature_Throwing, "Feature.Throwing");
UE_DEFINE_GAMEPLAY_TAG(Feature_Breakable, "Feature.Breakable");
UE_DEFINE_GAMEPLAY_TAG(Feature_Flammable, "Feature.Flammable");
UE_DEFINE_GAMEPLAY_TAG(Feature_Placeable, "Feature.Placeable");
UE_DEFINE_GAMEPLAY_TAG(Feature_Installable, "Feature.Installable");
UE_DEFINE_GAMEPLAY_TAG(Feature_QuestItem, "Feature.QuestItem");
UE_DEFINE_GAMEPLAY_TAG(Feature_Cursed, "Feature.Cursed");
}

View File

@ -0,0 +1,7 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
// FItemRuntimeProperties now stores its values in replicable TArrays (see header),
// so no custom net serializer is required - this translation unit is intentionally
// left empty and kept only to preserve the module's file layout.
#include "ItemRuntimeProperties.h"

View File

@ -0,0 +1,9 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Logging/LogMacros.h"
/** Shared log categories for the Item Interaction Ecosystem (section 33). */
ITEMCORE_API DECLARE_LOG_CATEGORY_EXTERN(LogItemCore, Log, All);

View File

@ -0,0 +1,19 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
/**
* ItemCore module.
* Foundation of the Item Interaction Ecosystem: base item structs, ids,
* runtime properties, location/state enums and shared log categories.
* Contains zero gameplay logic - only data types every other module shares.
*/
class FItemCoreModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};

View File

@ -0,0 +1,95 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "ItemEnums.generated.h"
/**
* Where an item instance currently lives (section 5).
* Every state transition is validated server-side through the transaction layer.
*/
UENUM(BlueprintType)
enum class EItemLocationType : uint8
{
None,
World,
Inventory,
Container,
Held,
Equipped,
PlacementPreview,
Installed,
NestedContainer
};
/** Per-property replication scope (section 22). */
UENUM(BlueprintType)
enum class EItemPropertyReplicationPolicy : uint8
{
None,
OwnerOnly,
Everyone,
NearbyOnly,
ServerOnly
};
/** How an item is currently carried/equipped on a character (section 11). */
UENUM(BlueprintType)
enum class EHeldItemMode : uint8
{
None,
EquippedFromInventory,
CarryAttached,
CarryPhysicsHandle,
PlacementPreview
};
/** High level interaction mode resolved per option (section 8.5). */
UENUM(BlueprintType)
enum class EItemInteractionMode : uint8
{
None,
InstantPickup,
OpenLootUI,
PickupToInventory,
EquipFromWorld,
EquipFromInventory,
CarryAttached,
CarryPhysicsHandle,
DropHeldItem,
Inspect,
Use,
UseHeldOnTarget,
Combine,
StartPlacement,
ConfirmPlacement,
CancelPlacement,
InstallIntoWorldSlot,
RemoveFromWorldSlot,
TransferContainerItem,
SplitStack,
MergeStack
};
/** Container layout / role (section 7.2). */
UENUM(BlueprintType)
enum class EInventoryContainerType : uint8
{
SlotList,
Grid,
Equipment,
Hotbar,
VehicleCargo,
LootBox,
KeyRing,
Wallet,
CraftingInput,
CraftingOutput
};

View File

@ -0,0 +1,92 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "ItemEnums.h"
#include "ItemRuntimeProperties.h"
#include "ItemLockData.h"
#include "ItemInstanceData.generated.h"
/**
* Runtime representation of a single item instance (section 4.3).
*
* Deliberately tiny: it never copies the static FItemDefinitionRow - it only
* stores the stable ItemId plus this instance's mutable state. Resolve the
* definition through UItemDatabaseSubsystem when you need icons/meshes/flags.
*/
USTRUCT(BlueprintType)
struct ITEMCORE_API FItemInstanceData
{
GENERATED_BODY()
/** Globally unique handle for this concrete stack/instance. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, SaveGame, Category = "Item")
FGuid InstanceId;
/** Stable design-time id (e.g. "ammo_9mm"). Never a localized string. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, SaveGame, Category = "Item")
FName ItemId;
UPROPERTY(EditAnywhere, BlueprintReadOnly, SaveGame, Category = "Item")
int32 Quantity = 1;
UPROPERTY(EditAnywhere, BlueprintReadOnly, SaveGame, Category = "Item")
FGameplayTagContainer StateTags;
UPROPERTY(EditAnywhere, BlueprintReadOnly, SaveGame, Category = "Item")
FItemRuntimeProperties RuntimeProperties;
UPROPERTY(EditAnywhere, BlueprintReadOnly, SaveGame, Category = "Item")
FGuid CurrentContainerId;
UPROPERTY(EditAnywhere, BlueprintReadOnly, SaveGame, Category = "Item")
int32 SlotIndex = INDEX_NONE;
UPROPERTY(EditAnywhere, BlueprintReadOnly, SaveGame, Category = "Item")
EItemLocationType LocationType = EItemLocationType::None;
/** Bumped when the item schema changes; drives save migration (section 26.2). */
UPROPERTY(EditAnywhere, BlueprintReadOnly, SaveGame, Category = "Item")
int32 ItemDataVersion = 1;
/** Transient server-side lock; not saved (section 20). */
UPROPERTY(BlueprintReadOnly, Transient, Category = "Item")
FItemLockData Lock;
FItemInstanceData() = default;
explicit FItemInstanceData(FName InItemId, int32 InQuantity = 1)
: InstanceId(FGuid::NewGuid())
, ItemId(InItemId)
, Quantity(InQuantity)
{
}
bool IsValid() const { return !ItemId.IsNone() && InstanceId.IsValid() && Quantity > 0; }
void EnsureInstanceId()
{
if (!InstanceId.IsValid())
{
InstanceId = FGuid::NewGuid();
}
}
/**
* Two instances can merge into one stack only when they are the same item,
* carry identical per-instance state and neither is locked. MaxStack is
* enforced by the inventory layer which has access to the definition.
*/
bool CanStackWith(const FItemInstanceData& Other) const
{
return ItemId == Other.ItemId
&& !Lock.bLocked && !Other.Lock.bLocked
&& StateTags == Other.StateTags
&& RuntimeProperties == Other.RuntimeProperties;
}
bool operator==(const FItemInstanceData& Other) const { return InstanceId == Other.InstanceId; }
bool operator!=(const FItemInstanceData& Other) const { return InstanceId != Other.InstanceId; }
};

View File

@ -0,0 +1,42 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "ItemLockData.generated.h"
/**
* Server-side soft lock placed on an item while a transaction owns it (section 20).
* Prevents dupes / conflicts: a locked item cannot be moved, taken, dropped,
* crafted, deleted or stacked by anyone but the locking actor.
*/
USTRUCT(BlueprintType)
struct ITEMCORE_API FItemLockData
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Item|Lock")
bool bLocked = false;
/** Lock.Carrying / Lock.Using / Lock.Transferring / ... */
UPROPERTY(BlueprintReadOnly, Category = "Item|Lock")
FGameplayTag LockReason;
UPROPERTY(BlueprintReadOnly, Category = "Item|Lock")
TWeakObjectPtr<AActor> LockedBy;
/** Server world time (seconds) at which the lock auto-expires; 0 = no expiry. */
UPROPERTY(BlueprintReadOnly, Category = "Item|Lock")
float LockExpireTime = 0.f;
bool IsLockedBy(const AActor* Actor) const { return bLocked && LockedBy.Get() == Actor; }
void Clear()
{
bLocked = false;
LockReason = FGameplayTag();
LockedBy = nullptr;
LockExpireTime = 0.f;
}
};

View File

@ -0,0 +1,88 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "NativeGameplayTags.h"
/**
* Native gameplay tags shipped with the framework.
*
* These cover the built-in behaviour the C++ layer reasons about directly
* (lock reasons, the common runtime property tags, the default action verbs).
* Project / designer specific tags (item types, world sockets, custom features)
* are expected to be declared in the project's tag config or DataTables, not here.
*/
namespace ItemEcosystemTags
{
// --- Runtime property tags (section 4.4) ---
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_Durability);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_Freshness);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_RotProgress);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_Temperature);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_Wetness);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_FuelAmount);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_MaxFuel);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_BatteryCharge);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_Contamination);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_NoiseLevel);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_SmellIntensity);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_BloodLevel);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_Radiation);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_Poison);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_CookedLevel);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_BurnLevel);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_Quality);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_Charge);
/** Server world-time (seconds) of the last lazy feature update (section 21.4). */
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Property_LastUpdateTime);
// --- State tags (section 4.3 StateTags) ---
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(State_Broken);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(State_Rotten);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(State_Wet);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(State_Contaminated);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(State_Empty);
// --- Lock reasons (section 20) ---
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Lock_Carrying);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Lock_Using);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Lock_Transferring);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Lock_Crafting);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Lock_Installing);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Lock_Replicating);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Lock_Placement);
// --- Action verbs (section 6.4 / 8.x) ---
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Pickup_Instant);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Pickup);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Open);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Carry);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Drop);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Equip);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Inspect);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Use);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Install);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Remove);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_Place);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Action_UseHeldOnTarget);
// --- Feature tags (section 6.3) ---
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Consumable);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Rotting);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Durability);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Battery);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Fuel);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Wetness);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Temperature);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_NoiseOnDrop);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Smell);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Container);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Equippable);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Throwing);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Breakable);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Flammable);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Placeable);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Installable);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_QuestItem);
ITEMCORE_API UE_DECLARE_GAMEPLAY_TAG_EXTERN(Feature_Cursed);
}

View File

@ -0,0 +1,190 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "ItemRuntimeProperties.generated.h"
/**
* Tag->value pairs (section 4.4).
*
* The TЗ sketched these as TMaps, but UHT forbids TMap inside replicated structs,
* and runtime properties must replicate (they live in FItemInstanceData). So they
* are stored as small replicable arrays instead; the typed accessors below hide
* that and behave like a map. N is tiny per item, so linear lookup is fine.
*/
USTRUCT(BlueprintType)
struct FItemFloatProperty
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
FGameplayTag Tag;
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
float Value = 0.f;
bool operator==(const FItemFloatProperty& O) const { return Tag == O.Tag && Value == O.Value; }
};
USTRUCT(BlueprintType)
struct FItemIntProperty
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
FGameplayTag Tag;
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
int32 Value = 0;
bool operator==(const FItemIntProperty& O) const { return Tag == O.Tag && Value == O.Value; }
};
USTRUCT(BlueprintType)
struct FItemBoolProperty
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
FGameplayTag Tag;
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
bool Value = false;
bool operator==(const FItemBoolProperty& O) const { return Tag == O.Tag && Value == O.Value; }
};
USTRUCT(BlueprintType)
struct FItemStringProperty
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
FGameplayTag Tag;
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
FString Value;
bool operator==(const FItemStringProperty& O) const { return Tag == O.Tag && Value == O.Value; }
};
/**
* Dynamic, per-instance properties keyed by gameplay tag (section 4.4).
* Only data differing from the definition defaults should be stored, to keep
* replication/save payloads small. Typed accessors fall back when a key is absent.
*/
USTRUCT(BlueprintType)
struct ITEMCORE_API FItemRuntimeProperties
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
TArray<FItemFloatProperty> FloatProperties;
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
TArray<FItemIntProperty> IntProperties;
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
TArray<FItemBoolProperty> BoolProperties;
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime")
TArray<FItemStringProperty> StringProperties;
bool IsEmpty() const
{
return FloatProperties.Num() == 0 && IntProperties.Num() == 0
&& BoolProperties.Num() == 0 && StringProperties.Num() == 0;
}
// --- Float ---
float GetFloat(const FGameplayTag& Tag, float Fallback = 0.f) const
{
for (const FItemFloatProperty& P : FloatProperties) { if (P.Tag == Tag) { return P.Value; } }
return Fallback;
}
bool HasFloat(const FGameplayTag& Tag) const
{
for (const FItemFloatProperty& P : FloatProperties) { if (P.Tag == Tag) { return true; } }
return false;
}
void SetFloat(const FGameplayTag& Tag, float Value)
{
for (FItemFloatProperty& P : FloatProperties) { if (P.Tag == Tag) { P.Value = Value; return; } }
FloatProperties.Add({ Tag, Value });
}
void RemoveFloat(const FGameplayTag& Tag)
{
FloatProperties.RemoveAll([&Tag](const FItemFloatProperty& P) { return P.Tag == Tag; });
}
// --- Int ---
int32 GetInt(const FGameplayTag& Tag, int32 Fallback = 0) const
{
for (const FItemIntProperty& P : IntProperties) { if (P.Tag == Tag) { return P.Value; } }
return Fallback;
}
bool HasInt(const FGameplayTag& Tag) const
{
for (const FItemIntProperty& P : IntProperties) { if (P.Tag == Tag) { return true; } }
return false;
}
void SetInt(const FGameplayTag& Tag, int32 Value)
{
for (FItemIntProperty& P : IntProperties) { if (P.Tag == Tag) { P.Value = Value; return; } }
IntProperties.Add({ Tag, Value });
}
// --- Bool ---
bool GetBool(const FGameplayTag& Tag, bool Fallback = false) const
{
for (const FItemBoolProperty& P : BoolProperties) { if (P.Tag == Tag) { return P.Value; } }
return Fallback;
}
bool HasBool(const FGameplayTag& Tag) const
{
for (const FItemBoolProperty& P : BoolProperties) { if (P.Tag == Tag) { return true; } }
return false;
}
void SetBool(const FGameplayTag& Tag, bool Value)
{
for (FItemBoolProperty& P : BoolProperties) { if (P.Tag == Tag) { P.Value = Value; return; } }
BoolProperties.Add({ Tag, Value });
}
// --- String ---
FString GetString(const FGameplayTag& Tag, const FString& Fallback = FString()) const
{
for (const FItemStringProperty& P : StringProperties) { if (P.Tag == Tag) { return P.Value; } }
return Fallback;
}
bool HasString(const FGameplayTag& Tag) const
{
for (const FItemStringProperty& P : StringProperties) { if (P.Tag == Tag) { return true; } }
return false;
}
void SetString(const FGameplayTag& Tag, const FString& Value)
{
for (FItemStringProperty& P : StringProperties) { if (P.Tag == Tag) { P.Value = Value; return; } }
StringProperties.Add({ Tag, Value });
}
bool operator==(const FItemRuntimeProperties& Other) const
{
return ArrayEqual(FloatProperties, Other.FloatProperties)
&& ArrayEqual(IntProperties, Other.IntProperties)
&& ArrayEqual(BoolProperties, Other.BoolProperties)
&& ArrayEqual(StringProperties, Other.StringProperties);
}
bool operator!=(const FItemRuntimeProperties& Other) const { return !(*this == Other); }
private:
template<typename T>
static bool ArrayEqual(const TArray<T>& A, const TArray<T>& B)
{
if (A.Num() != B.Num()) { return false; }
for (const T& Elem : A) { if (!B.Contains(Elem)) { return false; } }
return true;
}
};

View File

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

View File

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

View File

@ -0,0 +1,555 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemDatabaseSubsystem.h"
#include "ItemEcosystemSettings.h"
#include "ItemDatabaseLog.h"
#include "ItemNativeTags.h"
#include "Engine/DataTable.h"
#include "Engine/GameInstance.h"
void UItemDatabaseSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
RebuildCache();
#if !UE_BUILD_SHIPPING
TArray<FItemValidationIssue> Issues;
ValidateData(Issues);
for (const FItemValidationIssue& Issue : Issues)
{
if (Issue.bIsError)
{
UE_LOG(LogItemDatabase, Error, TEXT("[Validate] %s: %s"), *Issue.ItemId.ToString(), *Issue.Message);
}
else
{
UE_LOG(LogItemDatabase, Warning, TEXT("[Validate] %s: %s"), *Issue.ItemId.ToString(), *Issue.Message);
}
}
#endif
}
void UItemDatabaseSubsystem::Deinitialize()
{
Definitions.Empty();
PropertiesByItem.Empty();
FeaturesByItem.Empty();
InteractionsByItem.Empty();
Redirects.Empty();
ContainerDefs.Empty();
WorldSlotDefs.Empty();
LootByTable.Empty();
RecipesById.Empty();
bCacheBuilt = false;
Super::Deinitialize();
}
UItemDatabaseSubsystem* UItemDatabaseSubsystem::Get(const UObject* WorldContext)
{
if (!WorldContext)
{
return nullptr;
}
if (const UWorld* World = GEngine ? GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::ReturnNull) : nullptr)
{
if (UGameInstance* GI = World->GetGameInstance())
{
return GI->GetSubsystem<UItemDatabaseSubsystem>();
}
}
return nullptr;
}
void UItemDatabaseSubsystem::RebuildCache()
{
Definitions.Empty();
PropertiesByItem.Empty();
FeaturesByItem.Empty();
InteractionsByItem.Empty();
Redirects.Empty();
const UItemEcosystemSettings* Settings = UItemEcosystemSettings::Get();
if (!Settings)
{
UE_LOG(LogItemDatabase, Warning, TEXT("No ItemEcosystem settings found."));
return;
}
LoadDefinitions(Settings->ItemsTable.LoadSynchronous());
LoadProperties(Settings->PropertiesTable.LoadSynchronous());
LoadFeatures(Settings->FeaturesTable.LoadSynchronous());
LoadInteractions(Settings->InteractionsTable.LoadSynchronous());
LoadRedirects(Settings->RedirectsTable.LoadSynchronous());
LoadContainers(Settings->ContainersTable.LoadSynchronous());
LoadWorldSlots(Settings->WorldSlotsTable.LoadSynchronous());
LoadLoot(Settings->LootTable.LoadSynchronous());
LoadRecipes(Settings->RecipesTable.LoadSynchronous());
bCacheBuilt = true;
UE_LOG(LogItemDatabase, Log, TEXT("Item database built: %d items, %d redirects."),
Definitions.Num(), Redirects.Num());
}
void UItemDatabaseSubsystem::LoadDefinitions(UDataTable* Table)
{
if (!Table)
{
UE_LOG(LogItemDatabase, Warning, TEXT("DT_Items not assigned in Project Settings -> Item Ecosystem."));
return;
}
TArray<FItemDefinitionRow*> Rows;
Table->GetAllRows<FItemDefinitionRow>(TEXT("ItemDatabase.LoadDefinitions"), Rows);
for (const FItemDefinitionRow* Row : Rows)
{
if (!Row || Row->ItemId.IsNone())
{
continue;
}
Definitions.Add(Row->ItemId, *Row);
}
}
void UItemDatabaseSubsystem::LoadProperties(UDataTable* Table)
{
if (!Table)
{
return;
}
TArray<FItemPropertyRow*> Rows;
Table->GetAllRows<FItemPropertyRow>(TEXT("ItemDatabase.LoadProperties"), Rows);
for (const FItemPropertyRow* Row : Rows)
{
if (Row && !Row->ItemId.IsNone())
{
PropertiesByItem.FindOrAdd(Row->ItemId).Add(*Row);
}
}
}
void UItemDatabaseSubsystem::LoadFeatures(UDataTable* Table)
{
if (!Table)
{
return;
}
TArray<FItemFeatureRow*> Rows;
Table->GetAllRows<FItemFeatureRow>(TEXT("ItemDatabase.LoadFeatures"), Rows);
for (const FItemFeatureRow* Row : Rows)
{
if (Row && !Row->ItemId.IsNone())
{
FeaturesByItem.FindOrAdd(Row->ItemId).Add(*Row);
}
}
}
void UItemDatabaseSubsystem::LoadInteractions(UDataTable* Table)
{
if (!Table)
{
return;
}
TArray<FItemInteractionRow*> Rows;
Table->GetAllRows<FItemInteractionRow>(TEXT("ItemDatabase.LoadInteractions"), Rows);
for (const FItemInteractionRow* Row : Rows)
{
if (Row && !Row->ItemId.IsNone())
{
InteractionsByItem.FindOrAdd(Row->ItemId).Add(*Row);
}
}
}
void UItemDatabaseSubsystem::LoadRedirects(UDataTable* Table)
{
if (!Table)
{
return;
}
TArray<FItemRedirectRow*> Rows;
Table->GetAllRows<FItemRedirectRow>(TEXT("ItemDatabase.LoadRedirects"), Rows);
for (const FItemRedirectRow* Row : Rows)
{
if (Row && !Row->OldItemId.IsNone() && !Row->NewItemId.IsNone())
{
Redirects.Add(Row->OldItemId, Row->NewItemId);
}
}
}
void UItemDatabaseSubsystem::LoadContainers(UDataTable* Table)
{
if (!Table) { return; }
TArray<FContainerDefRow*> Rows;
Table->GetAllRows<FContainerDefRow>(TEXT("ItemDatabase.LoadContainers"), Rows);
for (const FContainerDefRow* Row : Rows)
{
if (Row && Row->ContainerTag.IsValid()) { ContainerDefs.Add(Row->ContainerTag, *Row); }
}
}
void UItemDatabaseSubsystem::LoadWorldSlots(UDataTable* Table)
{
if (!Table) { return; }
TArray<FWorldSlotDefRow*> Rows;
Table->GetAllRows<FWorldSlotDefRow>(TEXT("ItemDatabase.LoadWorldSlots"), Rows);
for (const FWorldSlotDefRow* Row : Rows)
{
if (Row && Row->SlotTag.IsValid()) { WorldSlotDefs.Add(Row->SlotTag, *Row); }
}
}
void UItemDatabaseSubsystem::LoadLoot(UDataTable* Table)
{
if (!Table) { return; }
TArray<FLootEntryRow*> Rows;
Table->GetAllRows<FLootEntryRow>(TEXT("ItemDatabase.LoadLoot"), Rows);
for (const FLootEntryRow* Row : Rows)
{
if (Row && !Row->TableId.IsNone()) { LootByTable.FindOrAdd(Row->TableId).Add(*Row); }
}
}
void UItemDatabaseSubsystem::LoadRecipes(UDataTable* Table)
{
if (!Table) { return; }
TArray<FCraftingRecipeRow*> Rows;
Table->GetAllRows<FCraftingRecipeRow>(TEXT("ItemDatabase.LoadRecipes"), Rows);
for (const FCraftingRecipeRow* Row : Rows)
{
if (Row && !Row->RecipeId.IsNone()) { RecipesById.Add(Row->RecipeId, *Row); }
}
}
EItemPropertyReplicationPolicy UItemDatabaseSubsystem::GetPropertyPolicy(FName ItemId, const FGameplayTag& PropertyTag) const
{
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId)))
{
for (const FItemPropertyRow& R : *Rows)
{
if (R.PropertyTag == PropertyTag) { return R.ReplicationPolicy; }
}
}
return EItemPropertyReplicationPolicy::None;
}
void UItemDatabaseSubsystem::GetServerOnlyProperties(FName ItemId, TArray<FGameplayTag>& Out) const
{
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId)))
{
for (const FItemPropertyRow& R : *Rows)
{
if (R.ReplicationPolicy == EItemPropertyReplicationPolicy::ServerOnly) { Out.Add(R.PropertyTag); }
}
}
}
bool UItemDatabaseSubsystem::FindContainerDef(FGameplayTag ContainerTag, FContainerDefRow& OutDef) const
{
if (const FContainerDefRow* Found = ContainerDefs.Find(ContainerTag)) { OutDef = *Found; return true; }
return false;
}
bool UItemDatabaseSubsystem::FindWorldSlotDef(FGameplayTag SlotTag, FWorldSlotDefRow& OutDef) const
{
if (const FWorldSlotDefRow* Found = WorldSlotDefs.Find(SlotTag)) { OutDef = *Found; return true; }
return false;
}
bool UItemDatabaseSubsystem::FindRecipe(FName RecipeId, FCraftingRecipeRow& OutRecipe) const
{
if (const FCraftingRecipeRow* Found = RecipesById.Find(RecipeId)) { OutRecipe = *Found; return true; }
return false;
}
void UItemDatabaseSubsystem::GetAllRecipes(TArray<FCraftingRecipeRow>& OutRecipes) const
{
RecipesById.GenerateValueArray(OutRecipes);
}
void UItemDatabaseSubsystem::GetLootEntries(FName TableId, TArray<FLootEntryRow>& Out) const
{
if (const TArray<FLootEntryRow>* Found = LootByTable.Find(TableId)) { Out = *Found; }
}
void UItemDatabaseSubsystem::RollLoot(FName TableId, int32 RollCount, TArray<FItemInstanceData>& OutItems) const
{
const TArray<FLootEntryRow>* Entries = LootByTable.Find(TableId);
if (!Entries || Entries->Num() == 0)
{
return;
}
float TotalWeight = 0.f;
for (const FLootEntryRow& E : *Entries) { TotalWeight += FMath::Max(0.f, E.Weight); }
if (TotalWeight <= 0.f)
{
return;
}
for (int32 Roll = 0; Roll < FMath::Max(1, RollCount); ++Roll)
{
float Pick = FMath::FRandRange(0.f, TotalWeight);
for (const FLootEntryRow& E : *Entries)
{
Pick -= FMath::Max(0.f, E.Weight);
if (Pick <= 0.f)
{
if (FMath::FRand() <= E.Chance && HasItem(E.ItemId))
{
const int32 Qty = FMath::RandRange(FMath::Max(1, E.MinQuantity), FMath::Max(E.MinQuantity, E.MaxQuantity));
OutItems.Add(MakeItemInstance(E.ItemId, Qty));
}
break;
}
}
}
}
FName UItemDatabaseSubsystem::ResolveRedirect(FName ItemId) const
{
// Follow the chain with a cycle guard.
FName Current = ItemId;
int32 Guard = 0;
while (const FName* Next = Redirects.Find(Current))
{
Current = *Next;
if (++Guard > 64)
{
UE_LOG(LogItemDatabase, Error, TEXT("Redirect cycle detected starting at %s."), *ItemId.ToString());
break;
}
}
return Current;
}
const FItemDefinitionRow* UItemDatabaseSubsystem::GetItemDefinition(FName ItemId) const
{
return Definitions.Find(ResolveRedirect(ItemId));
}
bool UItemDatabaseSubsystem::FindItemDefinition(FName ItemId, FItemDefinitionRow& OutDefinition) const
{
if (const FItemDefinitionRow* Def = GetItemDefinition(ItemId))
{
OutDefinition = *Def;
return true;
}
return false;
}
bool UItemDatabaseSubsystem::HasItem(FName ItemId) const
{
return Definitions.Contains(ResolveRedirect(ItemId));
}
void UItemDatabaseSubsystem::GetAllItemIds(TArray<FName>& OutItemIds) const
{
Definitions.GenerateKeyArray(OutItemIds);
}
int32 UItemDatabaseSubsystem::GetMaxStack(FName ItemId) const
{
const FItemDefinitionRow* Def = GetItemDefinition(ItemId);
return Def ? FMath::Max(1, Def->MaxStack) : 1;
}
void UItemDatabaseSubsystem::ApplyDefaultProperties(FName ItemId, FItemRuntimeProperties& Out) const
{
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId)))
{
for (const FItemPropertyRow& Row : *Rows)
{
if (Row.PropertyTag.IsValid())
{
Out.SetFloat(Row.PropertyTag, FMath::Clamp(Row.DefaultValue, Row.Min, Row.Max));
}
}
}
}
void UItemDatabaseSubsystem::ApplyMissingDefaultProperties(FName ItemId, FItemRuntimeProperties& Out) const
{
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId)))
{
for (const FItemPropertyRow& Row : *Rows)
{
if (Row.PropertyTag.IsValid() && !Out.HasFloat(Row.PropertyTag))
{
Out.SetFloat(Row.PropertyTag, FMath::Clamp(Row.DefaultValue, Row.Min, Row.Max));
}
}
}
}
void UItemDatabaseSubsystem::MigrateLoadedItem(FItemInstanceData& Item) const
{
// 1) Redirect retired ids to their replacement.
const FName Resolved = ResolveRedirect(Item.ItemId);
if (Resolved != Item.ItemId)
{
Item.ItemId = Resolved;
}
// 2) Fill in properties introduced after this item was saved.
if (Item.ItemDataVersion < CurrentItemDataVersion)
{
ApplyMissingDefaultProperties(Item.ItemId, Item.RuntimeProperties);
Item.ItemDataVersion = CurrentItemDataVersion;
}
Item.EnsureInstanceId();
}
FItemInstanceData UItemDatabaseSubsystem::MakeItemInstance(FName ItemId, int32 Quantity) const
{
const FName Resolved = ResolveRedirect(ItemId);
FItemInstanceData Instance(Resolved, FMath::Max(1, Quantity));
Instance.LocationType = EItemLocationType::None;
ApplyDefaultProperties(Resolved, Instance.RuntimeProperties);
return Instance;
}
void UItemDatabaseSubsystem::GetPropertyRows(FName ItemId, TArray<FItemPropertyRow>& Out) const
{
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId)))
{
Out = *Rows;
}
}
void UItemDatabaseSubsystem::GetFeatureRows(FName ItemId, TArray<FItemFeatureRow>& Out) const
{
if (const TArray<FItemFeatureRow>* Rows = FeaturesByItem.Find(ResolveRedirect(ItemId)))
{
Out = *Rows;
}
}
void UItemDatabaseSubsystem::GetFeatureRows(FName ItemId, const FGameplayTag& Feature, TArray<FItemFeatureRow>& Out) const
{
if (const TArray<FItemFeatureRow>* Rows = FeaturesByItem.Find(ResolveRedirect(ItemId)))
{
for (const FItemFeatureRow& Row : *Rows)
{
if (Row.Feature == Feature)
{
Out.Add(Row);
}
}
}
}
void UItemDatabaseSubsystem::GetInteractionRows(FName ItemId, TArray<FItemInteractionRow>& Out) const
{
if (const TArray<FItemInteractionRow>* Rows = InteractionsByItem.Find(ResolveRedirect(ItemId)))
{
Out = *Rows;
}
}
void UItemDatabaseSubsystem::ValidateData(TArray<FItemValidationIssue>& OutIssues) const
{
auto AddIssue = [&OutIssues](FName Id, const FString& Msg, bool bError)
{
FItemValidationIssue Issue;
Issue.ItemId = Id;
Issue.Message = Msg;
Issue.bIsError = bError;
OutIssues.Add(Issue);
};
for (const TPair<FName, FItemDefinitionRow>& Pair : Definitions)
{
const FName Id = Pair.Key;
const FItemDefinitionRow& Def = Pair.Value;
if (Def.ItemId.IsNone())
{
AddIssue(Id, TEXT("ItemId is empty."), true);
}
if (Def.MaxStack <= 0)
{
AddIssue(Id, TEXT("MaxStack <= 0."), true);
}
if (Def.GridSize.X <= 0 || Def.GridSize.Y <= 0)
{
AddIssue(Id, TEXT("GridSize must be >= 1 in both axes."), true);
}
if (Def.bCanInstantPickup && !Def.bCanStoreInInventory)
{
AddIssue(Id, TEXT("CanInstantPickup=true but CanStoreInInventory=false."), true);
}
if (Def.bCanEquip && Def.EquippedActorClass.IsNull())
{
AddIssue(Id, TEXT("CanEquip=true but EquippedActorClass is empty."), true);
}
if (Def.bCanWorldCarry && Def.WorldMesh.IsNull())
{
AddIssue(Id, TEXT("CanWorldCarry=true but WorldMesh is empty."), true);
}
if (Def.bCanInstallIntoWorldSlot && Def.CompatibleWorldSlots.IsEmpty())
{
AddIssue(Id, TEXT("CanInstallIntoWorldSlot=true but CompatibleWorldSlots is empty."), true);
}
if (Def.bCanPlace && !Def.PlacementRules.bCanPlaceOnGround
&& !Def.PlacementRules.bCanPlaceOnWall && !Def.PlacementRules.bCanPlaceOnVehicle
&& !Def.PlacementRules.bCanPlaceInWater)
{
AddIssue(Id, TEXT("CanPlace=true but PlacementRules forbid every surface."), true);
}
if (Def.bIsQuestCritical && Def.bCanDrop)
{
AddIssue(Id, TEXT("QuestCritical item is droppable (CanDrop=true)."), false);
}
// Feature <-> property consistency (section 28).
auto HasDefaultProperty = [this, Id](const FGameplayTag& Tag) -> bool
{
if (const TArray<FItemPropertyRow>* Rows = PropertiesByItem.Find(Id))
{
for (const FItemPropertyRow& R : *Rows)
{
if (R.PropertyTag == Tag) { return true; }
}
}
return false;
};
if (Def.HasFeature(ItemEcosystemTags::Feature_Battery) && !HasDefaultProperty(ItemEcosystemTags::Property_BatteryCharge))
{
AddIssue(Id, TEXT("Battery feature present but no Property.BatteryCharge default."), false);
}
if (Def.HasFeature(ItemEcosystemTags::Feature_Fuel) && !HasDefaultProperty(ItemEcosystemTags::Property_FuelAmount))
{
AddIssue(Id, TEXT("Fuel feature present but no Property.FuelAmount default."), false);
}
if (Def.HasFeature(ItemEcosystemTags::Feature_Rotting))
{
bool bHasRottenId = false;
if (const TArray<FItemFeatureRow>* Rows = FeaturesByItem.Find(Id))
{
for (const FItemFeatureRow& R : *Rows)
{
if (R.Feature == ItemEcosystemTags::Feature_Rotting && R.Param == FName("RottenItemId") && !R.Value.IsEmpty())
{
bHasRottenId = true;
break;
}
}
}
if (!bHasRottenId)
{
AddIssue(Id, TEXT("Rotting feature present but no RottenItemId param."), false);
}
}
}
// Redirect targets must exist.
for (const TPair<FName, FName>& Pair : Redirects)
{
if (!Definitions.Contains(ResolveRedirect(Pair.Value)))
{
AddIssue(Pair.Key, FString::Printf(TEXT("Redirect target '%s' not found in DT_Items."), *Pair.Value.ToString()), true);
}
}
}

View File

@ -0,0 +1,13 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemEcosystemSettings.h"
UItemEcosystemSettings::UItemEcosystemSettings()
{
CategoryName = FName("Game");
}
const UItemEcosystemSettings* UItemEcosystemSettings::Get()
{
return GetDefault<UItemEcosystemSettings>();
}

View File

@ -0,0 +1,129 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "GameplayTagContainer.h"
#include "ItemEnums.h"
#include "ItemAuxiliaryRows.generated.h"
/**
* DT_ItemProperties row (section 6.2): default runtime property for an item.
* Multiple rows share one ItemId, so the row name is arbitrary - the database
* indexes these by ItemId.
*/
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FItemPropertyRow : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Property")
FName ItemId;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Property")
FGameplayTag PropertyTag;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Property")
float DefaultValue = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Property")
float Min = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Property")
float Max = 1.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Property")
EItemPropertyReplicationPolicy ReplicationPolicy = EItemPropertyReplicationPolicy::OwnerOnly;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Property")
bool bSave = true;
};
/**
* DT_ItemFeatures row (section 6.3): one tunable parameter of a feature.
* e.g. raw_meat | Rotting | DecayRatePerHour | 0.08
* Value is stored as text so it can carry floats, ints or item ids uniformly.
*/
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FItemFeatureRow : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Feature")
FName ItemId;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Feature")
FGameplayTag Feature;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Feature")
FName Param;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Feature")
FString Value;
float GetFloat(float Fallback = 0.f) const
{
return Value.IsNumeric() ? FCString::Atof(*Value) : Fallback;
}
int32 GetInt(int32 Fallback = 0) const
{
return Value.IsNumeric() ? FCString::Atoi(*Value) : Fallback;
}
};
/**
* DT_ItemInteractions row (section 6.4): a data-driven interaction option.
* The interaction resolver turns matching rows into FInteractionOptions.
*/
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FItemInteractionRow : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
FName ItemId;
/** Context filter, e.g. "World", "Inventory", "Held+Vehicle", "Held+Generator". */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
FName Context;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
FGameplayTag ActionTag;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
EItemInteractionMode InteractionMode = EItemInteractionMode::None;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
int32 Priority = 0;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
bool bRequiresHold = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
float HoldDuration = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
float ActionDuration = 0.f;
/** Free-form requirement expression resolved by the requirement system (section 18). */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
FString Requirement;
/** Result verb, e.g. AddToInventory / HoldWorldItem / InstallIntoSlot / TransferFuel. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
FName Result;
};
/** DT_ItemRedirects row (section 26.1): maps a retired ItemId to its replacement. */
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FItemRedirectRow : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Redirect")
FName OldItemId;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Redirect")
FName NewItemId;
};

View File

@ -0,0 +1,121 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "GameplayTagContainer.h"
#include "ItemEnums.h"
#include "ItemContentRows.generated.h"
/** DT_Containers row (section 6): a reusable container template. */
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FContainerDefRow : public FTableRowBase
{
GENERATED_BODY()
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;
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;
};
/** DT_WorldSlots row (section 6/14): documents a world slot's contract. */
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FWorldSlotDefRow : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Slot")
FGameplayTag SlotTag;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Slot")
FGameplayTagContainer AcceptedItemTags;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "World Slot")
FName AttachSocketName;
};
/** DT_LootTables row (section 6): one weighted drop in a named loot table. */
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FLootEntryRow : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Loot")
FName TableId;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Loot")
FName ItemId;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Loot")
float Weight = 1.f;
/** 0..1 independent chance that this entry rolls at all. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Loot")
float Chance = 1.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Loot")
int32 MinQuantity = 1;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Loot")
int32 MaxQuantity = 1;
};
/** One required ingredient of a recipe. */
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FCraftingIngredient
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
FName ItemId;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
int32 Quantity = 1;
};
/** DT_CraftingRecipes row (section 6). */
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FCraftingRecipeRow : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
FName RecipeId;
/** Optional station requirement, e.g. Station.Workbench. Empty = craft anywhere. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
FGameplayTag StationTag;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
TArray<FCraftingIngredient> Inputs;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
FName OutputItemId;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
int32 OutputQuantity = 1;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Crafting")
float CraftTime = 0.f;
};

View File

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

View File

@ -0,0 +1,169 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "ItemDefinitionRow.h"
#include "ItemAuxiliaryRows.h"
#include "ItemContentRows.h"
#include "ItemInstanceData.h"
#include "ItemDatabaseSubsystem.generated.h"
/** One problem found by ValidateData (section 28). */
USTRUCT(BlueprintType)
struct FItemValidationIssue
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Validation")
FName ItemId;
UPROPERTY(BlueprintReadOnly, Category = "Validation")
FString Message;
UPROPERTY(BlueprintReadOnly, Category = "Validation")
bool bIsError = true;
};
/**
* Authoritative, read-only access point for all static item data (section 35 stage 1).
*
* Loads the configured data tables once, flattens them into fast lookup maps and
* exposes definition/property/feature/interaction queries plus redirect resolution
* and a data validator. Lives on the GameInstance so it is available on both
* server and clients; the tables themselves are never replicated (section 32).
*/
UCLASS()
class ITEMDATABASE_API UItemDatabaseSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
//~ USubsystem
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
/** Convenience accessor from any world context. */
static UItemDatabaseSubsystem* Get(const UObject* WorldContext);
/** (Re)load every configured table and rebuild the lookup caches. */
void RebuildCache();
// --- Definition lookup ---
/** Returns the definition for an item (after redirects), or null if unknown. C++ fast path. */
const FItemDefinitionRow* GetItemDefinition(FName ItemId) const;
UFUNCTION(BlueprintCallable, Category = "Item|Database")
bool FindItemDefinition(FName ItemId, FItemDefinitionRow& OutDefinition) const;
UFUNCTION(BlueprintPure, Category = "Item|Database")
bool HasItem(FName ItemId) const;
UFUNCTION(BlueprintPure, Category = "Item|Database")
void GetAllItemIds(TArray<FName>& OutItemIds) const;
/** Applies the redirect chain; returns the input unchanged if no redirect exists. */
UFUNCTION(BlueprintPure, Category = "Item|Database")
FName ResolveRedirect(FName ItemId) const;
// --- Instances ---
/**
* Builds a fresh instance of ItemId with a new GUID and its default runtime
* properties applied. The single correct way to mint an item (server-side).
*/
UFUNCTION(BlueprintCallable, Category = "Item|Database")
FItemInstanceData MakeItemInstance(FName ItemId, int32 Quantity = 1) const;
/** Writes the item's default runtime properties into Out (does not clear first). */
void ApplyDefaultProperties(FName ItemId, FItemRuntimeProperties& Out) const;
/** Adds only default properties that are missing (save migration, section 26.2). */
void ApplyMissingDefaultProperties(FName ItemId, FItemRuntimeProperties& Out) const;
/** Current schema version stamped onto fresh/migrated instances. */
static constexpr int32 CurrentItemDataVersion = 1;
/** Applies redirects + version migration to a loaded instance (section 26). */
UFUNCTION(BlueprintCallable, Category = "Item|Database")
void MigrateLoadedItem(UPARAM(ref) FItemInstanceData& Item) const;
UFUNCTION(BlueprintPure, Category = "Item|Database")
int32 GetMaxStack(FName ItemId) const;
// --- Auxiliary data ---
void GetPropertyRows(FName ItemId, TArray<FItemPropertyRow>& Out) const;
void GetFeatureRows(FName ItemId, TArray<FItemFeatureRow>& Out) const;
/** Feature rows for one specific feature tag (e.g. all Rotting params of raw_meat). */
void GetFeatureRows(FName ItemId, const FGameplayTag& Feature, TArray<FItemFeatureRow>& Out) const;
void GetInteractionRows(FName ItemId, TArray<FItemInteractionRow>& Out) const;
/** Per-property replication policy default (section 22), or None if unspecified. */
EItemPropertyReplicationPolicy GetPropertyPolicy(FName ItemId, const FGameplayTag& PropertyTag) const;
/** Property tags on an item that are flagged ServerOnly (never sent to clients). */
void GetServerOnlyProperties(FName ItemId, TArray<FGameplayTag>& Out) const;
// --- Containers / world slots / loot / recipes (section 6) ---
UFUNCTION(BlueprintPure, Category = "Item|Database")
bool FindContainerDef(FGameplayTag ContainerTag, FContainerDefRow& OutDef) const;
UFUNCTION(BlueprintPure, Category = "Item|Database")
bool FindWorldSlotDef(FGameplayTag SlotTag, FWorldSlotDefRow& OutDef) const;
UFUNCTION(BlueprintPure, Category = "Item|Database")
bool FindRecipe(FName RecipeId, FCraftingRecipeRow& OutRecipe) const;
UFUNCTION(BlueprintPure, Category = "Item|Database")
void GetAllRecipes(TArray<FCraftingRecipeRow>& OutRecipes) const;
void GetLootEntries(FName TableId, TArray<FLootEntryRow>& Out) const;
/** Rolls a loot table into a list of fresh item instances (server). */
UFUNCTION(BlueprintCallable, Category = "Item|Database")
void RollLoot(FName TableId, int32 RollCount, TArray<FItemInstanceData>& OutItems) const;
// --- Validation (section 28) ---
UFUNCTION(BlueprintCallable, Category = "Item|Database")
void ValidateData(TArray<FItemValidationIssue>& OutIssues) const;
private:
void LoadDefinitions(UDataTable* Table);
void LoadProperties(UDataTable* Table);
void LoadFeatures(UDataTable* Table);
void LoadInteractions(UDataTable* Table);
void LoadRedirects(UDataTable* Table);
void LoadContainers(UDataTable* Table);
void LoadWorldSlots(UDataTable* Table);
void LoadLoot(UDataTable* Table);
void LoadRecipes(UDataTable* Table);
/** ItemId -> definition. */
TMap<FName, FItemDefinitionRow> Definitions;
/** ItemId -> default property rows. */
TMap<FName, TArray<FItemPropertyRow>> PropertiesByItem;
/** ItemId -> feature parameter rows. */
TMap<FName, TArray<FItemFeatureRow>> FeaturesByItem;
/** ItemId -> interaction rows. */
TMap<FName, TArray<FItemInteractionRow>> InteractionsByItem;
/** Old -> new id. */
TMap<FName, FName> Redirects;
TMap<FGameplayTag, FContainerDefRow> ContainerDefs;
TMap<FGameplayTag, FWorldSlotDefRow> WorldSlotDefs;
TMap<FName, TArray<FLootEntryRow>> LootByTable;
TMap<FName, FCraftingRecipeRow> RecipesById;
bool bCacheBuilt = false;
};

View File

@ -0,0 +1,112 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "GameplayTagContainer.h"
#include "ItemPlacementRules.h"
#include "ItemDefinitionRow.generated.h"
class UTexture2D;
class UStaticMesh;
/**
* Static, design-time data for an item type (section 4.2 / 6.1 DT_Items).
*
* One row per ItemId. Assets are soft references so the database never force-loads
* meshes/icons just to answer a flag query (section 33). Never replicated; never
* saved - only the ItemId travels in FItemInstanceData.
*/
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FItemDefinitionRow : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Identity")
FName ItemId;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Identity")
FText DisplayName;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Identity", meta = (MultiLine = true))
FText Description;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Identity")
FGameplayTag ItemType;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Identity")
FGameplayTagContainer ItemTags;
// --- Visuals (soft refs, section 33) ---
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Visual")
TSoftObjectPtr<UTexture2D> Icon;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Visual")
TSoftObjectPtr<UStaticMesh> WorldMesh;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Visual")
TSoftClassPtr<AActor> PickupActorClass;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Visual")
TSoftClassPtr<AActor> EquippedActorClass;
// --- Stacking / weight / grid ---
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stack", meta = (ClampMin = "1"))
int32 MaxStack = 1;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stack", meta = (ClampMin = "0"))
float Weight = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stack")
FIntPoint GridSize = FIntPoint(1, 1);
// --- Capability flags (section 4.2) ---
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Capabilities")
bool bCanStoreInInventory = true;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Capabilities")
bool bCanInstantPickup = true;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Capabilities")
bool bCanWorldCarry = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Capabilities")
bool bCanEquip = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Capabilities")
bool bCanPlace = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Capabilities")
bool bCanInstallIntoWorldSlot = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Capabilities")
bool bCanDrop = true;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Capabilities")
bool bIsQuestCritical = false;
// --- Interaction defaults ---
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
FGameplayTag DefaultInteraction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
FGameplayTag AlternativeInteraction;
// --- Slot compatibility ---
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Slots")
FGameplayTagContainer AllowedEquipmentSlots;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Slots")
FGameplayTagContainer CompatibleWorldSlots;
// --- Features ---
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Features")
FGameplayTagContainer ItemFeatures;
// --- Placement (section 13.3); only meaningful when bCanPlace ---
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement", meta = (EditCondition = "bCanPlace"))
FItemPlacementRules PlacementRules;
bool HasFeature(const FGameplayTag& Feature) const { return ItemFeatures.HasTag(Feature); }
};

View File

@ -0,0 +1,63 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DeveloperSettings.h"
#include "ItemEcosystemSettings.generated.h"
class UDataTable;
/**
* Project Settings -> Game -> Item Ecosystem.
* Designers assign the data tables here; the database subsystem reads them on init.
* Keeping the wiring in settings means no code change to repoint tables (section 3).
*/
UCLASS(Config = Game, DefaultConfig, meta = (DisplayName = "Item Ecosystem"))
class ITEMDATABASE_API UItemEcosystemSettings : public UDeveloperSettings
{
GENERATED_BODY()
public:
UItemEcosystemSettings();
virtual FName GetCategoryName() const override { return FName("Game"); }
/** DT_Items - row struct FItemDefinitionRow. The one required table. */
UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable"))
TSoftObjectPtr<UDataTable> ItemsTable;
/** DT_ItemProperties - row struct FItemPropertyRow. */
UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable"))
TSoftObjectPtr<UDataTable> PropertiesTable;
/** DT_ItemFeatures - row struct FItemFeatureRow. */
UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable"))
TSoftObjectPtr<UDataTable> FeaturesTable;
/** DT_ItemInteractions - row struct FItemInteractionRow. */
UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable"))
TSoftObjectPtr<UDataTable> InteractionsTable;
/** DT_ItemRedirects - row struct FItemRedirectRow. */
UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable"))
TSoftObjectPtr<UDataTable> RedirectsTable;
/** DT_Containers - row struct FContainerDefRow. */
UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable"))
TSoftObjectPtr<UDataTable> ContainersTable;
/** DT_WorldSlots - row struct FWorldSlotDefRow. */
UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable"))
TSoftObjectPtr<UDataTable> WorldSlotsTable;
/** DT_LootTables - row struct FLootEntryRow. */
UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable"))
TSoftObjectPtr<UDataTable> LootTable;
/** DT_CraftingRecipes - row struct FCraftingRecipeRow. */
UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable"))
TSoftObjectPtr<UDataTable> RecipesTable;
static const UItemEcosystemSettings* Get();
};

View File

@ -0,0 +1,69 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "ItemPlacementRules.generated.h"
/** How a placeable item snaps when previewed/placed (section 13.3). */
UENUM(BlueprintType)
enum class EItemPlacementMode : uint8
{
FreeSurface,
SnapToGrid,
SnapToSocket
};
/**
* Static placement constraints for a placeable item (section 13.3).
* Evaluated locally for the ghost preview and re-validated on the server.
*/
USTRUCT(BlueprintType)
struct ITEMDATABASE_API FItemPlacementRules
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
EItemPlacementMode PlacementMode = EItemPlacementMode::FreeSurface;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
float MaxDistance = 400.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
bool bRequiresFlatSurface = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement", meta = (ClampMin = "0", ClampMax = "90"))
float MaxSlopeAngle = 35.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
FName CollisionProfile = FName("BlockAll");
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
FGameplayTagContainer AllowedSurfaceTags;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
FGameplayTagContainer BlockedSurfaceTags;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
float SnapToGrid = 0.f;
/** When PlacementMode == SnapToSocket, only sockets carrying this tag accept the item. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
FGameplayTag SnapToSocketTag;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
float MinDistanceFromOtherSameItems = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
bool bCanPlaceInWater = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
bool bCanPlaceOnVehicle = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
bool bCanPlaceOnWall = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Placement")
bool bCanPlaceOnGround = true;
};

View File

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

View File

@ -0,0 +1,252 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "Modules/ModuleManager.h"
#include "ItemDebugLog.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemDefinitionRow.h"
#include "InventoryComponent.h"
#include "ItemFeatureLibrary.h"
#include "ItemCraftingLibrary.h"
#include "PickupItemActor.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
#include "Engine/Engine.h"
DEFINE_LOG_CATEGORY(LogItemDebug);
// Implemented as the Item Debugger console toolkit (section 29).
namespace ItemDebugCmd
{
static APawn* GetPlayerPawn(UWorld* World)
{
APlayerController* PC = World ? World->GetFirstPlayerController() : nullptr;
return PC ? PC->GetPawn() : nullptr;
}
static UInventoryComponent* GetPlayerInventory(UWorld* World)
{
APawn* Pawn = GetPlayerPawn(World);
return Pawn ? Pawn->FindComponentByClass<UInventoryComponent>() : nullptr;
}
static void Screen(const FString& Msg, const FColor Color = FColor::Green)
{
UE_LOG(LogItemDebug, Log, TEXT("%s"), *Msg);
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 4.f, Color, Msg);
}
}
static void Give(const TArray<FString>& Args, UWorld* World)
{
if (Args.Num() < 1) { Screen(TEXT("Usage: ItemEco.Give <ItemId> [Qty]"), FColor::Yellow); return; }
UInventoryComponent* Inv = GetPlayerInventory(World);
UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(World);
if (!Inv || !DB) { Screen(TEXT("No player inventory / database."), FColor::Red); return; }
const FName Id(*Args[0]);
const int32 Qty = Args.Num() > 1 ? FMath::Max(1, FCString::Atoi(*Args[1])) : 1;
if (!DB->HasItem(Id)) { Screen(FString::Printf(TEXT("Unknown item '%s'"), *Id.ToString()), FColor::Red); return; }
FItemInstanceData Item = DB->MakeItemInstance(Id, Qty);
Inv->AddItem(Item);
Screen(FString::Printf(TEXT("[Inventory] gave %s x%d"), *Id.ToString(), Qty));
}
static void Spawn(const TArray<FString>& Args, UWorld* World)
{
if (Args.Num() < 1) { Screen(TEXT("Usage: ItemEco.Spawn <ItemId> [Qty]"), FColor::Yellow); return; }
APawn* Pawn = GetPlayerPawn(World);
if (!Pawn) { Screen(TEXT("No player pawn."), FColor::Red); return; }
const FName Id(*Args[0]);
const int32 Qty = Args.Num() > 1 ? FMath::Max(1, FCString::Atoi(*Args[1])) : 1;
const FTransform Xf(Pawn->GetActorRotation(), Pawn->GetActorLocation() + Pawn->GetActorForwardVector() * 200.f + FVector(0, 0, 50.f));
if (APickupItemActor::SpawnPickup(World, Id, Qty, Xf))
{
Screen(FString::Printf(TEXT("Spawned pickup %s x%d"), *Id.ToString(), Qty));
}
else
{
Screen(FString::Printf(TEXT("Failed to spawn '%s'"), *Id.ToString()), FColor::Red);
}
}
static void Clear(const TArray<FString>&, UWorld* World)
{
UInventoryComponent* Inv = GetPlayerInventory(World);
if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; }
TArray<FItemInstanceData> Items;
Inv->GetAllItems(Items);
for (const FItemInstanceData& It : Items) { Inv->RemoveItem(It.InstanceId); }
Screen(FString::Printf(TEXT("Cleared %d items."), Items.Num()));
}
static void Dump(const TArray<FString>&, UWorld* World)
{
UInventoryComponent* Inv = GetPlayerInventory(World);
if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; }
TArray<FItemInstanceData> Items;
Inv->GetAllItems(Items);
Screen(FString::Printf(TEXT("--- Inventory (%d entries) ---"), Items.Num()), FColor::Cyan);
for (const FItemInstanceData& It : Items)
{
UE_LOG(LogItemDebug, Log, TEXT(" %s x%d [container %s slot %d]"),
*It.ItemId.ToString(), It.Quantity, *It.CurrentContainerId.ToString(), It.SlotIndex);
}
}
static void Validate(const TArray<FString>&, UWorld* World)
{
UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(World);
if (!DB) { Screen(TEXT("No database."), FColor::Red); return; }
TArray<FItemValidationIssue> Issues;
DB->ValidateData(Issues);
Screen(FString::Printf(TEXT("Validation: %d issue(s)."), Issues.Num()), Issues.Num() ? FColor::Yellow : FColor::Green);
for (const FItemValidationIssue& Issue : Issues)
{
UE_LOG(LogItemDebug, Warning, TEXT(" [%s] %s: %s"),
Issue.bIsError ? TEXT("ERROR") : TEXT("WARN"), *Issue.ItemId.ToString(), *Issue.Message);
}
}
// Mutates the first inventory item via the feature library.
static void MutateFirst(UWorld* World, TFunctionRef<void(FItemInstanceData&)> Mutator, const TCHAR* What)
{
UInventoryComponent* Inv = GetPlayerInventory(World);
if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; }
TArray<FItemInstanceData> Items;
Inv->GetAllItems(Items);
if (Items.Num() == 0) { Screen(TEXT("Inventory empty."), FColor::Yellow); return; }
FItemInstanceData Item = Items[0];
Mutator(Item);
Inv->UpdateItem(Item);
Screen(FString::Printf(TEXT("%s %s"), What, *Item.ItemId.ToString()));
}
static void Rot(const TArray<FString>&, UWorld* World)
{
UInventoryComponent* Inv = GetPlayerInventory(World);
if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; }
TArray<FItemInstanceData> Items;
Inv->GetAllItems(Items);
if (Items.Num() == 0) { Screen(TEXT("Inventory empty."), FColor::Yellow); return; }
FItemInstanceData Item = Items[0];
FName TransformId;
UItemFeatureLibrary::ForceRot(World, Item, TransformId);
if (!TransformId.IsNone())
{
UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(World);
FItemInstanceData NewItem = DB ? DB->MakeItemInstance(TransformId, Item.Quantity) : FItemInstanceData(TransformId, Item.Quantity);
Inv->RemoveItem(Item.InstanceId);
Inv->AddItem(NewItem);
Screen(FString::Printf(TEXT("Rotted -> %s"), *TransformId.ToString()));
}
else
{
Inv->UpdateItem(Item);
Screen(FString::Printf(TEXT("Rotted %s"), *Item.ItemId.ToString()));
}
}
static void Break(const TArray<FString>&, UWorld* World)
{
MutateFirst(World, [](FItemInstanceData& It) { UItemFeatureLibrary::BreakItem(It); }, TEXT("Broke"));
}
static void DrainBattery(const TArray<FString>& Args, UWorld* World)
{
const float Amt = Args.Num() > 0 ? FCString::Atof(*Args[0]) : 0.25f;
MutateFirst(World, [Amt](FItemInstanceData& It) { UItemFeatureLibrary::DrainBattery(It, Amt, 1.f); }, TEXT("Drained battery of"));
}
static void AddFuel(const TArray<FString>& Args, UWorld* World)
{
const float Amt = Args.Num() > 0 ? FCString::Atof(*Args[0]) : 5.f;
MutateFirst(World, [Amt](FItemInstanceData& It) { UItemFeatureLibrary::AddFuel(It, Amt, 9999.f); }, TEXT("Added fuel to"));
}
static void UnlockAll(const TArray<FString>&, UWorld* World)
{
UInventoryComponent* Inv = GetPlayerInventory(World);
if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; }
TArray<FItemInstanceData> Items;
Inv->GetAllItems(Items);
int32 Count = 0;
for (const FItemInstanceData& It : Items) { if (Inv->ClearItemLock(It.InstanceId)) { ++Count; } }
Screen(FString::Printf(TEXT("Unlocked %d items."), Count));
}
static void RollLoot(const TArray<FString>& Args, UWorld* World)
{
if (Args.Num() < 1) { Screen(TEXT("Usage: ItemEco.RollLoot <TableId> [Rolls]"), FColor::Yellow); return; }
UInventoryComponent* Inv = GetPlayerInventory(World);
if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; }
const int32 Rolls = Args.Num() > 1 ? FMath::Max(1, FCString::Atoi(*Args[1])) : 5;
UItemCraftingLibrary::RollLootIntoInventory(World, Inv, FName(*Args[0]), Rolls);
Screen(FString::Printf(TEXT("Rolled loot '%s' x%d"), *Args[0], Rolls));
}
static void Craft(const TArray<FString>& Args, UWorld* World)
{
if (Args.Num() < 1) { Screen(TEXT("Usage: ItemEco.Craft <RecipeId>"), FColor::Yellow); return; }
UInventoryComponent* Inv = GetPlayerInventory(World);
if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; }
const bool bOk = UItemCraftingLibrary::Craft(World, Inv, FName(*Args[0]), FGameplayTag());
Screen(FString::Printf(TEXT("Craft '%s': %s"), *Args[0], bOk ? TEXT("OK") : TEXT("FAILED")), bOk ? FColor::Green : FColor::Red);
}
}
// --- Console command registration ---
static FAutoConsoleCommandWithWorldAndArgs GCmdGive(
TEXT("ItemEco.Give"), TEXT("Give <ItemId> [Qty] to the local player inventory."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::Give));
static FAutoConsoleCommandWithWorldAndArgs GCmdSpawn(
TEXT("ItemEco.Spawn"), TEXT("Spawn <ItemId> [Qty] as a world pickup in front of the player."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::Spawn));
static FAutoConsoleCommandWithWorldAndArgs GCmdClear(
TEXT("ItemEco.Clear"), TEXT("Clear the local player inventory."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::Clear));
static FAutoConsoleCommandWithWorldAndArgs GCmdDump(
TEXT("ItemEco.Dump"), TEXT("Dump the local player inventory to the log."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::Dump));
static FAutoConsoleCommandWithWorldAndArgs GCmdValidate(
TEXT("ItemEco.Validate"), TEXT("Run data validation on the item database."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::Validate));
static FAutoConsoleCommandWithWorldAndArgs GCmdRot(
TEXT("ItemEco.Rot"), TEXT("Force-rot the first inventory item."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::Rot));
static FAutoConsoleCommandWithWorldAndArgs GCmdBreak(
TEXT("ItemEco.Break"), TEXT("Break the first inventory item."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::Break));
static FAutoConsoleCommandWithWorldAndArgs GCmdDrain(
TEXT("ItemEco.DrainBattery"), TEXT("DrainBattery [Amount] from the first item."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::DrainBattery));
static FAutoConsoleCommandWithWorldAndArgs GCmdAddFuel(
TEXT("ItemEco.AddFuel"), TEXT("AddFuel [Amount] to the first item."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::AddFuel));
static FAutoConsoleCommandWithWorldAndArgs GCmdUnlock(
TEXT("ItemEco.UnlockAll"), TEXT("Clear all item locks in the player inventory."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::UnlockAll));
static FAutoConsoleCommandWithWorldAndArgs GCmdRollLoot(
TEXT("ItemEco.RollLoot"), TEXT("RollLoot <TableId> [Rolls] into the player inventory."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::RollLoot));
static FAutoConsoleCommandWithWorldAndArgs GCmdCraft(
TEXT("ItemEco.Craft"), TEXT("Craft <RecipeId> using the player inventory."),
FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::Craft));
IMPLEMENT_MODULE(FDefaultModuleImpl, ItemDebug)

View File

@ -0,0 +1,83 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemDebugSubsystem.h"
#include "InteractionTraceComponent.h"
#include "InteractionResolver.h"
#include "InteractionTypes.h"
#include "HeldItemComponent.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/PlayerController.h"
#include "Engine/Engine.h"
static TAutoConsoleVariable<int32> CVarItemDebugHUD(
TEXT("ItemEco.DebugHUD"), 0,
TEXT("Draw the Item Ecosystem on-screen debugger (held item / target / interactions)."),
ECVF_Cheat);
void UItemDebugSubsystem::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (CVarItemDebugHUD.GetValueOnGameThread() <= 0 || !GEngine)
{
return;
}
UWorld* World = GetWorld();
APlayerController* PC = World ? World->GetFirstPlayerController() : nullptr;
APawn* Pawn = PC ? PC->GetPawn() : nullptr;
if (!Pawn)
{
return;
}
int32 Key = 9000;
auto Line = [&Key](const FString& Text, const FColor Color = FColor::White)
{
GEngine->AddOnScreenDebugMessage(Key++, 0.f, Color, Text);
};
Line(TEXT("== Item Ecosystem Debug =="), FColor::Cyan);
if (const UHeldItemComponent* Held = Pawn->FindComponentByClass<UHeldItemComponent>())
{
FItemInstanceData HeldItem;
if (Held->GetHeldItem(HeldItem))
{
Line(FString::Printf(TEXT("Held: %s"), *HeldItem.ItemId.ToString()), FColor::Yellow);
}
else
{
Line(TEXT("Held: -"));
}
}
AActor* Target = nullptr;
if (const UInteractionTraceComponent* Trace = Pawn->FindComponentByClass<UInteractionTraceComponent>())
{
Target = Trace->CurrentTarget;
}
Line(FString::Printf(TEXT("Target: %s"), *GetNameSafe(Target)), FColor::Green);
if (Target)
{
TArray<FInteractionOption> Options;
UInteractionResolver::GetAvailableInteractions(Pawn, Target, Options);
for (const FInteractionOption& Opt : Options)
{
Line(FString::Printf(TEXT(" [%d] %s%s"),
Opt.Priority, *Opt.DisplayText.ToString(),
Opt.bIsEnabled ? TEXT("") : TEXT(" (disabled)")),
Opt.bIsEnabled ? FColor::White : FColor::Silver);
}
}
}
TStatId UItemDebugSubsystem::GetStatId() const
{
RETURN_QUICK_DECLARE_CYCLE_STAT(UItemDebugSubsystem, STATGROUP_Tickables);
}
bool UItemDebugSubsystem::DoesSupportWorldType(const EWorldType::Type WorldType) const
{
return WorldType == EWorldType::Game || WorldType == EWorldType::PIE;
}

View File

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

View File

@ -0,0 +1,24 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/WorldSubsystem.h"
#include "ItemDebugSubsystem.generated.h"
/**
* On-screen runtime debugger (section 29). Toggle with the console variable
* `ItemEco.DebugHUD 1`. Draws the local player's held item, current interaction
* target and the available interactions each frame. Read-only / client-local.
*/
UCLASS()
class ITEMDEBUG_API UItemDebugSubsystem : public UTickableWorldSubsystem
{
GENERATED_BODY()
public:
//~ FTickableGameObject
virtual void Tick(float DeltaTime) override;
virtual TStatId GetStatId() const override;
virtual bool DoesSupportWorldType(const EWorldType::Type WorldType) const override;
};

View File

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

View File

@ -0,0 +1,158 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemDeveloperModule.h"
#include "ItemEcosystemSettings.h"
#include "ItemDefinitionRow.h"
#include "Engine/DataTable.h"
#include "ToolMenus.h"
#include "Framework/Notifications/NotificationManager.h"
#include "Widgets/Notifications/SNotificationList.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
DEFINE_LOG_CATEGORY_STATIC(LogItemDeveloper, Log, All);
#define LOCTEXT_NAMESPACE "ItemDeveloper"
void FItemDeveloperModule::StartupModule()
{
UToolMenus::RegisterStartupCallback(
FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FItemDeveloperModule::RegisterMenus));
}
void FItemDeveloperModule::ShutdownModule()
{
UToolMenus::UnRegisterStartupCallback(this);
UToolMenus::UnregisterOwner(this);
}
void FItemDeveloperModule::RegisterMenus()
{
FToolMenuOwnerScoped OwnerScoped(this);
UToolMenu* ToolsMenu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu.Tools");
if (!ToolsMenu)
{
return;
}
FToolMenuSection& Section = ToolsMenu->AddSection("ItemEcosystem", LOCTEXT("ItemEcosystemSection", "Item Ecosystem"));
Section.AddMenuEntry(
"ValidateItemData",
LOCTEXT("ValidateItemData", "Validate Item Data"),
LOCTEXT("ValidateItemDataTip", "Run data validation over DT_Items (section 28)."),
FSlateIcon(),
FUIAction(FExecuteAction::CreateStatic(&FItemDeveloperModule::ValidateAllItems)));
Section.AddMenuEntry(
"ExportItemsCsv",
LOCTEXT("ExportItemsCsv", "Export DT_Items to CSV"),
LOCTEXT("ExportItemsCsvTip", "Write DT_Items to Saved/ItemEcosystem/DT_Items.csv (section 27)."),
FSlateIcon(),
FUIAction(FExecuteAction::CreateStatic(&FItemDeveloperModule::ExportItemsToCsv)));
Section.AddMenuEntry(
"ReportMissingAssets",
LOCTEXT("ReportMissingAssets", "Report: Items Missing Icon/Mesh"),
LOCTEXT("ReportMissingAssetsTip", "List items with no Icon or WorldMesh (section 27)."),
FSlateIcon(),
FUIAction(FExecuteAction::CreateStatic(&FItemDeveloperModule::ReportMissingAssets)));
}
void FItemDeveloperModule::ExportItemsToCsv()
{
const UItemEcosystemSettings* Settings = UItemEcosystemSettings::Get();
UDataTable* ItemsTable = Settings ? Settings->ItemsTable.LoadSynchronous() : nullptr;
if (!ItemsTable)
{
FNotificationInfo Info(LOCTEXT("NoItemsTable", "DT_Items is not assigned."));
Info.ExpireDuration = 5.f;
FSlateNotificationManager::Get().AddNotification(Info);
return;
}
const FString Csv = ItemsTable->GetTableAsCSV();
const FString Path = FPaths::ProjectSavedDir() / TEXT("ItemEcosystem") / TEXT("DT_Items.csv");
const bool bOk = FFileHelper::SaveStringToFile(Csv, *Path);
const FText Msg = bOk
? FText::Format(LOCTEXT("ExportOk", "Exported DT_Items to {0}"), FText::FromString(Path))
: LOCTEXT("ExportFail", "Failed to write CSV.");
FNotificationInfo Info(Msg);
Info.ExpireDuration = 6.f;
FSlateNotificationManager::Get().AddNotification(Info);
UE_LOG(LogItemDeveloper, Log, TEXT("CSV export: %s -> %s"), bOk ? TEXT("OK") : TEXT("FAIL"), *Path);
}
void FItemDeveloperModule::ReportMissingAssets()
{
const UItemEcosystemSettings* Settings = UItemEcosystemSettings::Get();
UDataTable* ItemsTable = Settings ? Settings->ItemsTable.LoadSynchronous() : nullptr;
if (!ItemsTable)
{
return;
}
int32 Missing = 0;
TArray<FItemDefinitionRow*> Rows;
ItemsTable->GetAllRows<FItemDefinitionRow>(TEXT("ItemDeveloper.ReportMissing"), Rows);
for (const FItemDefinitionRow* Row : Rows)
{
if (!Row) { continue; }
if (Row->Icon.IsNull()) { ++Missing; UE_LOG(LogItemDeveloper, Warning, TEXT("%s: no Icon"), *Row->ItemId.ToString()); }
if (Row->WorldMesh.IsNull()) { ++Missing; UE_LOG(LogItemDeveloper, Warning, TEXT("%s: no WorldMesh"), *Row->ItemId.ToString()); }
}
FNotificationInfo Info(FText::Format(LOCTEXT("MissingResult", "Asset report: {0} missing reference(s). See Output Log."), FText::AsNumber(Missing)));
Info.ExpireDuration = 6.f;
FSlateNotificationManager::Get().AddNotification(Info);
}
void FItemDeveloperModule::ValidateAllItems()
{
const UItemEcosystemSettings* Settings = UItemEcosystemSettings::Get();
UDataTable* ItemsTable = Settings ? Settings->ItemsTable.LoadSynchronous() : nullptr;
int32 Errors = 0;
int32 Warnings = 0;
auto Report = [&](const FName& Id, const FString& Msg, bool bError)
{
bError ? ++Errors : ++Warnings;
UE_LOG(LogItemDeveloper, Warning, TEXT("[%s] %s: %s"), bError ? TEXT("ERROR") : TEXT("WARN"), *Id.ToString(), *Msg);
};
if (!ItemsTable)
{
UE_LOG(LogItemDeveloper, Error, TEXT("DT_Items is not assigned in Project Settings -> Item Ecosystem."));
}
else
{
TArray<FItemDefinitionRow*> Rows;
ItemsTable->GetAllRows<FItemDefinitionRow>(TEXT("ItemDeveloper.Validate"), Rows);
for (const FItemDefinitionRow* Row : Rows)
{
if (!Row) { continue; }
const FName Id = Row->ItemId;
if (Id.IsNone()) { Report(Id, TEXT("ItemId is empty."), true); }
if (Row->MaxStack <= 0) { Report(Id, TEXT("MaxStack <= 0."), true); }
if (Row->GridSize.X <= 0 || Row->GridSize.Y <= 0) { Report(Id, TEXT("GridSize must be >= 1."), true); }
if (Row->bCanInstantPickup && !Row->bCanStoreInInventory) { Report(Id, TEXT("InstantPickup but not storable."), true); }
if (Row->bCanEquip && Row->EquippedActorClass.IsNull()) { Report(Id, TEXT("CanEquip but no EquippedActorClass."), true); }
if (Row->bCanWorldCarry && Row->WorldMesh.IsNull()) { Report(Id, TEXT("CanWorldCarry but no WorldMesh."), true); }
if (Row->bCanInstallIntoWorldSlot && Row->CompatibleWorldSlots.IsEmpty()) { Report(Id, TEXT("CanInstall but no CompatibleWorldSlots."), true); }
if (Row->bIsQuestCritical && Row->bCanDrop) { Report(Id, TEXT("QuestCritical item is droppable."), false); }
}
UE_LOG(LogItemDeveloper, Log, TEXT("Validated %d item rows."), Rows.Num());
}
const FText Summary = FText::Format(
LOCTEXT("ValidateResult", "Item validation: {0} error(s), {1} warning(s). See Output Log."),
FText::AsNumber(Errors), FText::AsNumber(Warnings));
FNotificationInfo Info(Summary);
Info.ExpireDuration = 6.f;
FSlateNotificationManager::Get().AddNotification(Info);
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FItemDeveloperModule, ItemDeveloper)

View File

@ -0,0 +1,25 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
/**
* Editor-only tooling for the Item Ecosystem (section 27-28).
* Registers a "Item Ecosystem" menu under Tools with data validation. The full
* Item Ecosystem Editor (browser/filters/templates) is intended to be built on
* top of this as an Editor Utility Widget driving UItemDatabaseSubsystem.
*/
class FItemDeveloperModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
private:
void RegisterMenus();
static void ValidateAllItems();
static void ExportItemsToCsv();
static void ReportMissingAssets();
};

View File

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

View File

@ -0,0 +1,9 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "InteractableComponent.h"
UInteractableComponent::UInteractableComponent()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}

View File

@ -0,0 +1,162 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "InteractionAction.h"
#include "InteractableComponent.h"
#include "ItemInteractionLog.h"
#include "ItemNativeTags.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemDefinitionRow.h"
#include "InventoryComponent.h"
#include "GameFramework/Character.h"
namespace
{
/** Picks the item a requirement should inspect: held first, else the target item. */
const FItemInstanceData* ResolveSubjectItem(const FInteractionContext& Ctx)
{
if (Ctx.bHasHeldItem) { return &Ctx.HeldItem; }
if (Ctx.bHasTargetItem) { return &Ctx.TargetItem; }
return nullptr;
}
bool ItemHasTag(const UObject* WorldCtx, const FItemInstanceData& Item, const FGameplayTag& Tag)
{
if (const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldCtx))
{
if (const FItemDefinitionRow* Def = DB->GetItemDefinition(Item.ItemId))
{
return Def->ItemTags.HasTag(Tag) || Def->ItemType.MatchesTag(Tag);
}
}
return false;
}
bool EvaluateRequirement(const FInteractionRequirement& Req, const FInteractionContext& Ctx)
{
switch (Req.RequirementType)
{
case EInteractionRequirementType::None:
return true;
case EInteractionRequirementType::HasInventorySpace:
{
if (!Ctx.PlayerInventory) { return false; }
const FItemInstanceData* Subject = Ctx.bHasTargetItem ? &Ctx.TargetItem : ResolveSubjectItem(Ctx);
if (!Subject) { return true; }
TArray<FInventoryContainer> Containers;
Ctx.PlayerInventory->GetContainers(Containers);
for (const FInventoryContainer& C : Containers)
{
if (Ctx.PlayerInventory->CanAcceptItem(*Subject, C.ContainerId))
{
return true;
}
}
return false;
}
case EInteractionRequirementType::HasItemWithTag:
{
if (!Ctx.PlayerInventory) { return false; }
TArray<FItemInstanceData> Items;
Ctx.PlayerInventory->GetAllItems(Items);
for (const FItemInstanceData& It : Items)
{
if (ItemHasTag(Ctx.PlayerInventory.Get(), It, Req.RequiredTag)) { return true; }
}
return false;
}
case EInteractionRequirementType::HasHeldItemWithTag:
return Ctx.bHasHeldItem && ItemHasTag(Ctx.Character.Get(), Ctx.HeldItem, Req.RequiredTag);
case EInteractionRequirementType::PropertyGreaterThan:
{
const FItemInstanceData* Subject = ResolveSubjectItem(Ctx);
return Subject && Subject->RuntimeProperties.GetFloat(Req.RequiredTag) > Req.RequiredValue;
}
case EInteractionRequirementType::PropertyLessThan:
{
const FItemInstanceData* Subject = ResolveSubjectItem(Ctx);
return Subject && Subject->RuntimeProperties.GetFloat(Req.RequiredTag) < Req.RequiredValue;
}
case EInteractionRequirementType::TargetHasFreeSlotTag:
// Re-validated against the world slot component at execution time (stage 8).
return Ctx.TargetActor != nullptr;
case EInteractionRequirementType::NotWet:
{
const FItemInstanceData* Subject = ResolveSubjectItem(Ctx);
return !Subject || Subject->RuntimeProperties.GetFloat(ItemEcosystemTags::Property_Wetness) < 0.5f;
}
case EInteractionRequirementType::NotBroken:
{
const FItemInstanceData* Subject = ResolveSubjectItem(Ctx);
return !Subject || !Subject->StateTags.HasTag(ItemEcosystemTags::State_Broken);
}
default:
return true;
}
}
}
bool UInteractionAction::CanExecute_Implementation(const FInteractionContext& Context, FText& OutDisabledReason) const
{
for (const FInteractionRequirement& Req : Requirements)
{
if (!EvaluateRequirement(Req, Context))
{
OutDisabledReason = Req.FailureText.IsEmpty()
? NSLOCTEXT("ItemInteraction", "RequirementFailed", "Requirement not met")
: Req.FailureText;
return false;
}
}
return true;
}
void UInteractionAction::Execute_Implementation(const FInteractionContext& Context)
{
// Base does nothing; concrete actions / the interaction component handle effects.
UE_LOG(LogItemInteraction, Verbose, TEXT("UInteractionAction '%s' base Execute (no-op)."), *ActionTag.ToString());
}
FInteractionOption UInteractionAction::BuildOption(const FInteractionContext& Context) const
{
FInteractionOption Option;
Option.ActionTag = ActionTag;
Option.Mode = Mode;
Option.DisplayText = DisplayText;
Option.Priority = Priority;
Option.bRequiresHold = bRequiresHold;
Option.HoldDuration = HoldDuration;
Option.ActionDuration = Execution.bHasDuration ? Execution.Duration : 0.f;
FText Reason;
Option.bIsEnabled = CanExecute(Context, Reason);
Option.DisabledReason = Reason;
Option.Request.Mode = Mode;
Option.Request.ActionTag = ActionTag;
Option.Request.TargetActor = Context.TargetActor;
if (Context.bHasTargetItem) { Option.Request.ItemInstanceId = Context.TargetItem.InstanceId; }
else if (Context.bHasHeldItem) { Option.Request.ItemInstanceId = Context.HeldItem.InstanceId; }
return Option;
}
UWorld* UInteractionAction::GetWorld() const
{
if (HasAllFlags(RF_ClassDefaultObject))
{
return nullptr;
}
if (const UObject* Outer = GetOuter())
{
return Outer->GetWorld();
}
return nullptr;
}

View File

@ -0,0 +1,337 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "InteractionResolver.h"
#include "InteractableComponent.h"
#include "InteractionAction.h"
#include "ItemInteractionInterfaces.h"
#include "ItemInteractionLog.h"
#include "ItemNativeTags.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemAuxiliaryRows.h"
#include "InventoryComponent.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/Character.h"
#include "GameFramework/PlayerController.h"
#define LOCTEXT_NAMESPACE "ItemInteraction"
namespace
{
FInteractionOption MakeOption(const FInteractionContext& Ctx, FGameplayTag ActionTag, EItemInteractionMode Mode,
const FText& Text, int32 Priority, bool bRequiresHold = false, float HoldDuration = 0.f, float ActionDuration = 0.f)
{
FInteractionOption Opt;
Opt.ActionTag = ActionTag;
Opt.Mode = Mode;
Opt.DisplayText = Text;
Opt.Priority = Priority;
Opt.bRequiresHold = bRequiresHold;
Opt.HoldDuration = HoldDuration;
Opt.ActionDuration = ActionDuration;
Opt.bIsEnabled = true;
Opt.Request.Mode = Mode;
Opt.Request.ActionTag = ActionTag;
Opt.Request.TargetActor = Ctx.TargetActor;
if (Ctx.bHasHeldItem) { Opt.Request.ItemInstanceId = Ctx.HeldItem.InstanceId; }
if (Ctx.bHasTargetItem) { Opt.Request.ItemInstanceId = Ctx.TargetItem.InstanceId; }
return Opt;
}
/**
* Evaluates a data-driven requirement string like "FuelAmount>0" or
* "BatteryCharge>=0.2" against a subject item's runtime properties (section 18).
* The left side maps to the Property.<Name> tag. Empty string = always true.
*/
bool EvaluateRequirementString(const FItemInstanceData& Subject, const FString& Req)
{
FString Trimmed = Req.TrimStartAndEnd();
if (Trimmed.IsEmpty())
{
return true;
}
// Find the comparison operator (order matters: check 2-char first).
const TCHAR* Ops[] = { TEXT(">="), TEXT("<="), TEXT("=="), TEXT(">"), TEXT("<") };
FString Op, Left, Right;
for (const TCHAR* Candidate : Ops)
{
int32 Idx;
if (Trimmed.FindChar(Candidate[0], Idx))
{
if (Trimmed.Mid(Idx, FCString::Strlen(Candidate)) == Candidate)
{
Op = Candidate;
Left = Trimmed.Left(Idx).TrimStartAndEnd();
Right = Trimmed.Mid(Idx + FCString::Strlen(Candidate)).TrimStartAndEnd();
break;
}
}
}
if (Op.IsEmpty())
{
return true; // unparseable -> don't block
}
const FGameplayTag PropTag = FGameplayTag::RequestGameplayTag(FName(*(FString(TEXT("Property.")) + Left)), false);
const float Value = Subject.RuntimeProperties.GetFloat(PropTag, 0.f);
const float Threshold = FCString::Atof(*Right);
if (Op == TEXT(">")) { return Value > Threshold; }
if (Op == TEXT("<")) { return Value < Threshold; }
if (Op == TEXT(">=")) { return Value >= Threshold; }
if (Op == TEXT("<=")) { return Value <= Threshold; }
if (Op == TEXT("==")) { return FMath::IsNearlyEqual(Value, Threshold); }
return true;
}
bool AnyContainerAccepts(UInventoryComponent* Inv, const FItemInstanceData& Item)
{
if (!Inv) { return false; }
TArray<FInventoryContainer> Containers;
Inv->GetContainers(Containers);
for (const FInventoryContainer& C : Containers)
{
if (Inv->CanAcceptItem(Item, C.ContainerId)) { return true; }
}
return false;
}
}
bool UInteractionResolver::ResolveHeldItem(APawn* Instigator, FItemInstanceData& OutItem)
{
if (!Instigator) { return false; }
auto TryObject = [&OutItem](UObject* Obj) -> bool
{
if (Obj && Obj->Implements<UHeldItemInterface>())
{
IHeldItemInterface* Held = Cast<IHeldItemInterface>(Obj);
if (Held && Held->HasHeldItem())
{
return Held->GetHeldItem(OutItem);
}
}
return false;
};
if (TryObject(Instigator)) { return true; }
for (UActorComponent* Comp : Instigator->GetComponents())
{
if (TryObject(Comp)) { return true; }
}
return false;
}
FInteractionContext UInteractionResolver::BuildContext(APawn* Instigator, AActor* TargetActor)
{
FInteractionContext Ctx;
Ctx.TargetActor = TargetActor;
Ctx.Character = Cast<ACharacter>(Instigator);
if (Instigator)
{
Ctx.PlayerController = Cast<APlayerController>(Instigator->GetController());
Ctx.PlayerInventory = Instigator->FindComponentByClass<UInventoryComponent>();
Ctx.bHasHeldItem = ResolveHeldItem(Instigator, Ctx.HeldItem);
}
if (TargetActor)
{
Ctx.TargetInteractable = TargetActor->FindComponentByClass<UInteractableComponent>();
if (Ctx.TargetInteractable)
{
Ctx.WorldTags = Ctx.TargetInteractable->InteractionTags;
}
if (TargetActor->Implements<UWorldItemProvider>())
{
if (IWorldItemProvider* Provider = Cast<IWorldItemProvider>(TargetActor))
{
Ctx.bHasTargetItem = Provider->GetProvidedItem(Ctx.TargetItem);
}
}
}
return Ctx;
}
void UInteractionResolver::GatherCapabilityOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out)
{
if (!Ctx.bHasTargetItem)
{
return;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(Ctx.TargetActor);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(Ctx.TargetItem.ItemId) : nullptr;
if (!Def)
{
return;
}
const FText Name = Def->DisplayName.IsEmpty() ? FText::FromName(Ctx.TargetItem.ItemId) : Def->DisplayName;
if (Def->bCanInstantPickup || Def->bCanStoreInInventory)
{
const EItemInteractionMode Mode = Def->bCanInstantPickup ? EItemInteractionMode::InstantPickup : EItemInteractionMode::PickupToInventory;
const FGameplayTag Tag = Def->bCanInstantPickup ? ItemEcosystemTags::Action_Pickup_Instant : ItemEcosystemTags::Action_Pickup;
FText Text = Ctx.TargetItem.Quantity > 1
? FText::Format(LOCTEXT("PickUpQty", "Pick up {0} x{1}"), Name, FText::AsNumber(Ctx.TargetItem.Quantity))
: FText::Format(LOCTEXT("PickUp", "Pick up {0}"), Name);
FInteractionOption Opt = MakeOption(Ctx, Tag, Mode, Text, 600);
if (!AnyContainerAccepts(Ctx.PlayerInventory, Ctx.TargetItem))
{
Opt.bIsEnabled = false;
Opt.DisabledReason = LOCTEXT("InventoryFull", "Inventory Full");
}
Out.Add(Opt);
}
if (Def->bCanWorldCarry)
{
FInteractionOption Opt = MakeOption(Ctx, ItemEcosystemTags::Action_Carry, EItemInteractionMode::CarryAttached,
FText::Format(LOCTEXT("Carry", "Carry {0}"), Name), 500);
if (Ctx.bHasHeldItem)
{
Opt.bIsEnabled = false;
Opt.DisabledReason = LOCTEXT("HandsFull", "Hands Full");
}
Out.Add(Opt);
}
}
void UInteractionResolver::GatherContainerOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out)
{
if (!Ctx.TargetActor)
{
return;
}
// A target that owns an inventory (and isn't the player themselves) can be opened.
UInventoryComponent* TargetInv = Ctx.TargetActor->FindComponentByClass<UInventoryComponent>();
if (!TargetInv || TargetInv == Ctx.PlayerInventory)
{
return;
}
FText Label = (Ctx.TargetInteractable && !Ctx.TargetInteractable->DisplayName.IsEmpty())
? FText::Format(LOCTEXT("OpenNamed", "Open {0}"), Ctx.TargetInteractable->DisplayName)
: LOCTEXT("Open", "Open");
Out.Add(MakeOption(Ctx, ItemEcosystemTags::Action_Open, EItemInteractionMode::OpenLootUI, Label, 700));
}
void UInteractionResolver::GatherDataDrivenOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out)
{
const UObject* WorldCtx = Ctx.TargetActor ? static_cast<const UObject*>(Ctx.TargetActor) : static_cast<const UObject*>(Ctx.Character);
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldCtx);
if (!DB)
{
return;
}
// For a "Held+Vehicle" style context, the suffix after '+' must match one of the
// target's world tags or its interactable type (loose, case-insensitive).
auto TargetMatchesSuffix = [&Ctx](const FString& Suffix) -> bool
{
if (Suffix.IsEmpty()) { return true; }
if (Ctx.TargetInteractable && Ctx.TargetInteractable->InteractableType.IsValid()
&& Ctx.TargetInteractable->InteractableType.ToString().Contains(Suffix))
{
return true;
}
for (const FGameplayTag& Tag : Ctx.WorldTags)
{
if (Tag.ToString().Contains(Suffix)) { return true; }
}
return false;
};
auto AppendRows = [&](const FItemInstanceData& Item, bool bHeldContext)
{
TArray<FItemInteractionRow> Rows;
DB->GetInteractionRows(Item.ItemId, Rows);
for (const FItemInteractionRow& Row : Rows)
{
const FString Context = Row.Context.ToString();
const bool bIsHeldRow = Context.StartsWith(TEXT("Held"));
if (bHeldContext != bIsHeldRow)
{
continue;
}
if (bIsHeldRow)
{
FString Suffix;
Context.Split(TEXT("+"), nullptr, &Suffix);
if (!TargetMatchesSuffix(Suffix)) { continue; }
}
FInteractionOption Opt = MakeOption(Ctx, Row.ActionTag, Row.InteractionMode,
FText::FromName(Row.ActionTag.GetTagName()), Row.Priority,
Row.bRequiresHold, Row.HoldDuration, Row.ActionDuration);
Opt.Request.ItemInstanceId = Item.InstanceId;
Opt.Request.Quantity = Item.Quantity;
// Data-driven requirement gate (section 18).
if (!Row.Requirement.IsEmpty() && !EvaluateRequirementString(Item, Row.Requirement))
{
Opt.bIsEnabled = false;
Opt.DisabledReason = FText::FromString(Row.Requirement);
}
Out.Add(Opt);
}
};
if (Ctx.bHasTargetItem) { AppendRows(Ctx.TargetItem, /*bHeldContext*/ false); }
if (Ctx.bHasHeldItem) { AppendRows(Ctx.HeldItem, /*bHeldContext*/ true); }
}
void UInteractionResolver::GatherActionClassOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out)
{
if (!Ctx.TargetInteractable)
{
return;
}
for (const UInteractionAction* Action : Ctx.TargetInteractable->AvailableActions)
{
if (Action)
{
Out.Add(Action->BuildOption(Ctx));
}
}
}
void UInteractionResolver::GetAvailableInteractions(APawn* Instigator, AActor* TargetActor, TArray<FInteractionOption>& OutOptions)
{
if (!TargetActor)
{
return;
}
const FInteractionContext Ctx = BuildContext(Instigator, TargetActor);
GatherActionClassOptions(Ctx, OutOptions);
GatherCapabilityOptions(Ctx, OutOptions);
GatherContainerOptions(Ctx, OutOptions);
GatherDataDrivenOptions(Ctx, OutOptions);
OutOptions.StableSort([](const FInteractionOption& A, const FInteractionOption& B)
{
return A.Priority > B.Priority;
});
}
bool UInteractionResolver::GetPrimaryInteraction(APawn* Instigator, AActor* TargetActor, FInteractionOption& OutOption)
{
TArray<FInteractionOption> Options;
GetAvailableInteractions(Instigator, TargetActor, Options);
for (const FInteractionOption& Opt : Options)
{
if (Opt.bIsEnabled)
{
OutOption = Opt;
return true;
}
}
if (Options.Num() > 0)
{
OutOption = Options[0]; // disabled, but lets UI show the reason
return true;
}
return false;
}
#undef LOCTEXT_NAMESPACE

View File

@ -0,0 +1,86 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "InteractionTraceComponent.h"
#include "InteractableComponent.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
UInteractionTraceComponent::UInteractionTraceComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.TickInterval = 0.1f; // 10 Hz is plenty for prompts
SetIsReplicatedByDefault(false); // purely local
}
void UInteractionTraceComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
TickTrace();
}
bool UInteractionTraceComponent::GetViewPoint(FVector& OutLocation, FRotator& OutRotation) const
{
const APawn* Pawn = Cast<APawn>(GetOwner());
const APlayerController* PC = Pawn ? Cast<APlayerController>(Pawn->GetController()) : nullptr;
if (!PC || !PC->IsLocalController())
{
return false;
}
PC->GetPlayerViewPoint(OutLocation, OutRotation);
return true;
}
void UInteractionTraceComponent::TickTrace()
{
FVector ViewLoc;
FRotator ViewRot;
if (!GetViewPoint(ViewLoc, ViewRot))
{
SetTarget(nullptr, nullptr);
return;
}
const FVector End = ViewLoc + ViewRot.Vector() * TraceDistance;
FCollisionQueryParams Params(SCENE_QUERY_STAT(InteractionTrace), false, GetOwner());
Params.bTraceComplex = false;
FHitResult Hit;
const bool bHit = GetWorld()->SweepSingleByChannel(
Hit, ViewLoc, End, FQuat::Identity, TraceChannel,
FCollisionShape::MakeSphere(TraceRadius), Params);
AActor* HitActor = bHit ? Hit.GetActor() : nullptr;
UInteractableComponent* Interactable = HitActor ? HitActor->FindComponentByClass<UInteractableComponent>() : nullptr;
if (Interactable)
{
// Distance gate against the component's own reach.
const float DistSq = FVector::DistSquared(ViewLoc, Hit.ImpactPoint);
if (DistSq > FMath::Square(Interactable->InteractionDistance))
{
Interactable = nullptr;
HitActor = nullptr;
}
}
else
{
HitActor = nullptr;
}
LastHitLocation = Hit.ImpactPoint;
LastHitNormal = Hit.ImpactNormal;
SetTarget(HitActor, Interactable);
}
void UInteractionTraceComponent::SetTarget(AActor* NewTarget, UInteractableComponent* NewInteractable)
{
if (CurrentTarget == NewTarget && CurrentInteractable == NewInteractable)
{
return;
}
CurrentTarget = NewTarget;
CurrentInteractable = NewInteractable;
OnTargetChanged.Broadcast(NewTarget, NewInteractable);
}

View File

@ -0,0 +1,97 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemInteractionActions.h"
#include "ItemInteractionLog.h"
#include "ItemInteractionInterfaces.h"
#include "ItemNativeTags.h"
#include "InventoryComponent.h"
#include "GameFramework/Character.h"
// --- Instant Pickup ---
UInteractionAction_InstantPickup::UInteractionAction_InstantPickup()
{
ActionTag = ItemEcosystemTags::Action_Pickup_Instant;
Mode = EItemInteractionMode::InstantPickup;
Priority = 600;
DisplayText = NSLOCTEXT("ItemInteraction", "PickUp", "Pick Up");
}
void UInteractionAction_InstantPickup::Execute_Implementation(const FInteractionContext& Context)
{
if (!Context.TargetActor || !Context.PlayerInventory || !Context.TargetActor->Implements<UWorldItemProvider>())
{
return;
}
IWorldItemProvider* Provider = Cast<IWorldItemProvider>(Context.TargetActor);
FItemInstanceData Item;
if (!Provider || !Provider->IsItemAvailable() || !Provider->GetProvidedItem(Item))
{
return;
}
const int32 Before = Item.Quantity;
Context.PlayerInventory->AddItem(Item);
if (Before - Item.Quantity <= 0)
{
return;
}
if (Item.Quantity <= 0) { Provider->NotifyItemTaken(Context.Character.Get()); }
else { Provider->UpdateProvidedItem(Item); }
}
// --- Open Loot UI (client-side; no server effect) ---
UInteractionAction_OpenLootUI::UInteractionAction_OpenLootUI()
{
ActionTag = ItemEcosystemTags::Action_Open;
Mode = EItemInteractionMode::OpenLootUI;
Priority = 700;
DisplayText = NSLOCTEXT("ItemInteraction", "Open", "Open");
}
// --- Inspect ---
UInteractionAction_Inspect::UInteractionAction_Inspect()
{
ActionTag = ItemEcosystemTags::Action_Inspect;
Mode = EItemInteractionMode::Inspect;
Priority = 400;
DisplayText = NSLOCTEXT("ItemInteraction", "Inspect", "Inspect");
}
void UInteractionAction_Inspect::Execute_Implementation(const FInteractionContext& Context)
{
UE_LOG(LogItemInteraction, Verbose, TEXT("Inspect %s"), *GetNameSafe(Context.TargetActor));
}
// --- Combine / Transfer / Split / Merge ---
// These are inventory-UI verbs; their execution runs through the interaction
// component's inventory RPCs. The classes exist as authoring units (section 16).
UInteractionAction_Combine::UInteractionAction_Combine()
{
Mode = EItemInteractionMode::Combine;
Priority = 300;
DisplayText = NSLOCTEXT("ItemInteraction", "Combine", "Combine");
}
UInteractionAction_TransferContainerItem::UInteractionAction_TransferContainerItem()
{
Mode = EItemInteractionMode::TransferContainerItem;
Priority = 300;
DisplayText = NSLOCTEXT("ItemInteraction", "Transfer", "Transfer");
}
UInteractionAction_SplitStack::UInteractionAction_SplitStack()
{
Mode = EItemInteractionMode::SplitStack;
Priority = 300;
DisplayText = NSLOCTEXT("ItemInteraction", "Split", "Split Stack");
}
UInteractionAction_MergeStack::UInteractionAction_MergeStack()
{
Mode = EItemInteractionMode::MergeStack;
Priority = 300;
DisplayText = NSLOCTEXT("ItemInteraction", "Merge", "Merge Stack");
}

View File

@ -0,0 +1,520 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "ItemInteractionComponent.h"
#include "InteractionResolver.h"
#include "InteractableComponent.h"
#include "InteractionAction.h"
#include "ItemInteractionInterfaces.h"
#include "ItemInteractionLog.h"
#include "ItemNativeTags.h"
#include "InventoryComponent.h"
#include "ItemTransaction.h"
#include "GameFramework/Pawn.h"
#include "Engine/World.h"
#include "Net/UnrealNetwork.h"
UItemInteractionComponent::UItemInteractionComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.bStartWithTickEnabled = false;
SetIsReplicatedByDefault(true);
}
void UItemInteractionComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME_CONDITION(UItemInteractionComponent, ActiveAction, COND_OwnerOnly);
}
void UItemInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (!bHasPending || !GetOwner() || !GetOwner()->HasAuthority())
{
return;
}
// Interrupt if the player walked away from the target (section 19).
if (PendingRequest.TargetActor && PendingMaxDistanceSq > 0.f)
{
const float DistSq = FVector::DistSquared(GetOwner()->GetActorLocation(), PendingRequest.TargetActor->GetActorLocation());
if (DistSq > PendingMaxDistanceSq)
{
AbortTimedAction();
return;
}
}
if (GetWorld()->GetTimeSeconds() >= PendingEndTime)
{
CompleteTimedAction();
}
}
APawn* UItemInteractionComponent::GetPawn() const
{
return Cast<APawn>(GetOwner());
}
void UItemInteractionComponent::RequestInteraction(const FInteractionOption& Option)
{
// Opening a container is a purely local UI action: the shared container already
// replicates to this client, so no server round-trip is needed (section 23).
if (Option.Mode == EItemInteractionMode::OpenLootUI)
{
OnOpenContainer.Broadcast(Option.Request.TargetActor);
return;
}
FInteractionRequest Request = Option.Request;
if (!Request.RequestId.IsValid())
{
Request.RequestId = FGuid::NewGuid();
}
SendRequest(Request);
}
void UItemInteractionComponent::SendRequest(const FInteractionRequest& Request)
{
Server_RequestInteraction(Request);
}
void UItemInteractionComponent::Server_RequestInteraction_Implementation(const FInteractionRequest& Request)
{
FInteractionContext Context;
FInteractionOption Authorized;
if (!AuthorizeRequest(Request, Context, Authorized))
{
Client_InteractionResult(Request.RequestId, false);
return;
}
// Timed / hold actions run over time with interruption (section 19); instant ones execute now.
const bool bTimed = Authorized.ActionDuration > 0.f || Authorized.bRequiresHold;
if (bTimed && !bHasPending)
{
BeginTimedAction(Context, Authorized, Request);
return;
}
const bool bSuccess = ExecuteAuthorized(Context, Authorized, Request);
Client_InteractionResult(Request.RequestId, bSuccess);
}
bool UItemInteractionComponent::AuthorizeRequest(const FInteractionRequest& Request, FInteractionContext& OutContext, FInteractionOption& OutOption) const
{
APawn* Pawn = GetPawn();
AActor* Target = Request.TargetActor;
if (!Pawn || !Target)
{
UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: missing pawn/target."), *Request.ActionTag.ToString());
return false;
}
// Distance + line of sight gate (section 9.3-9.4).
OutContext = UInteractionResolver::BuildContext(Pawn, Target);
if (UInteractableComponent* Interactable = OutContext.TargetInteractable)
{
const float MaxDist = Interactable->InteractionDistance + ServerDistanceTolerance;
if (FVector::DistSquared(Pawn->GetActorLocation(), Target->GetActorLocation()) > FMath::Square(MaxDist))
{
UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: out of range."), *Request.ActionTag.ToString());
return false;
}
if (Interactable->bIsBusy)
{
UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: target busy."), *Request.ActionTag.ToString());
return false;
}
}
// Re-resolve and require the requested option to actually be available + enabled (section 9.8-9.9).
TArray<FInteractionOption> Options;
UInteractionResolver::GetAvailableInteractions(Pawn, Target, Options);
for (const FInteractionOption& Opt : Options)
{
if (Opt.ActionTag == Request.ActionTag && Opt.Mode == Request.Mode)
{
if (!Opt.bIsEnabled)
{
UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: %s"),
*Request.ActionTag.ToString(), *Opt.DisabledReason.ToString());
return false;
}
OutOption = Opt;
return true;
}
}
UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: option not available."), *Request.ActionTag.ToString());
return false;
}
bool UItemInteractionComponent::ServerExecuteMode(const FInteractionContext& Context, EItemInteractionMode Mode, FGameplayTag ActionTag)
{
if (!GetOwner() || !GetOwner()->HasAuthority())
{
return false;
}
FInteractionOption Option;
Option.Mode = Mode;
Option.ActionTag = ActionTag;
FInteractionRequest Request;
Request.Mode = Mode;
Request.ActionTag = ActionTag;
Request.TargetActor = Context.TargetActor;
if (Context.bHasHeldItem) { Request.ItemInstanceId = Context.HeldItem.InstanceId; }
else if (Context.bHasTargetItem) { Request.ItemInstanceId = Context.TargetItem.InstanceId; }
return ExecuteAuthorized(Context, Option, Request);
}
bool UItemInteractionComponent::ExecuteAuthorized(const FInteractionContext& Context, const FInteractionOption& Option, const FInteractionRequest& Request)
{
switch (Option.Mode)
{
case EItemInteractionMode::InstantPickup:
case EItemInteractionMode::PickupToInventory:
return ExecutePickup(Context);
case EItemInteractionMode::TransferContainerItem:
return ExecuteTransfer(Context, Request);
default:
// Anything else routes to a matching action-class on the interactable.
// Built-in carry/equip/install/placement modes are implemented in their
// respective stages (held/equipment/world-slot/placement modules).
if (ExecuteActionClass(Context, Option))
{
return true;
}
UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] Mode %d has no handler yet."), (int32)Option.Mode);
return false;
}
}
bool UItemInteractionComponent::ExecutePickup(const FInteractionContext& Context)
{
if (!Context.TargetActor || !Context.PlayerInventory || !Context.TargetActor->Implements<UWorldItemProvider>())
{
return false;
}
IWorldItemProvider* Provider = Cast<IWorldItemProvider>(Context.TargetActor);
if (!Provider || !Provider->IsItemAvailable())
{
return false;
}
FItemInstanceData Item;
if (!Provider->GetProvidedItem(Item) || !Item.IsValid())
{
return false;
}
const int32 Before = Item.Quantity;
Context.PlayerInventory->AddItem(Item); // Item.Quantity now holds the leftover
const int32 Added = Before - Item.Quantity;
if (Added <= 0)
{
return false;
}
UE_LOG(LogItemInteraction, Log, TEXT("[Interaction] %s picked up %s x%d"),
*GetNameSafe(GetOwner()), *Item.ItemId.ToString(), Added);
if (Item.Quantity <= 0)
{
Provider->NotifyItemTaken(GetOwner());
}
else
{
Provider->UpdateProvidedItem(Item);
}
return true;
}
bool UItemInteractionComponent::ExecuteActionClass(const FInteractionContext& Context, const FInteractionOption& Option)
{
if (!Context.TargetInteractable)
{
return false;
}
for (UInteractionAction* Action : Context.TargetInteractable->AvailableActions)
{
if (Action && Action->ActionTag == Option.ActionTag)
{
FText Reason;
if (!Action->CanExecute(Context, Reason))
{
UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] %s: %s"),
*Option.ActionTag.ToString(), *Reason.ToString());
return false;
}
Action->Execute(Context);
return true;
}
}
return false;
}
// --- Cross-inventory transfer (loot) ---
bool UItemInteractionComponent::ExecuteTransfer(const FInteractionContext& Context, const FInteractionRequest& Request)
{
UInventoryComponent* PlayerInv = Context.PlayerInventory;
UInventoryComponent* StorageInv = Context.TargetActor ? Context.TargetActor->FindComponentByClass<UInventoryComponent>() : nullptr;
if (!PlayerInv)
{
return false;
}
// Determine which inventory currently holds the item -> source; the other -> dest.
FItemInstanceData Item;
UInventoryComponent* Source = nullptr;
UInventoryComponent* Dest = nullptr;
if (PlayerInv->FindItem(Request.ItemInstanceId, Item)) { Source = PlayerInv; Dest = StorageInv; }
else if (StorageInv && StorageInv->FindItem(Request.ItemInstanceId, Item)) { Source = StorageInv; Dest = PlayerInv; }
if (!Source || !Dest || !Item.IsValid() || Item.Lock.bLocked)
{
return false;
}
// Pick the destination container.
FGuid DestContainer = Request.TargetContainerId;
if (!DestContainer.IsValid() || !Dest->CanAcceptItem(Item, DestContainer))
{
TArray<FInventoryContainer> Containers;
Dest->GetContainers(Containers);
DestContainer = FGuid();
for (const FInventoryContainer& C : Containers)
{
if (Dest->CanAcceptItem(Item, C.ContainerId)) { DestContainer = C.ContainerId; break; }
}
}
if (!DestContainer.IsValid())
{
UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] Transfer failed: no space."));
return false;
}
// Atomic remove-from-source + add-to-dest (rolls back if either fails).
FItemTransaction Tx;
Tx.Instigator = GetOwner();
Tx.Remove(Source, Item.InstanceId).Add(Dest, Item);
if (!Tx.Apply())
{
UE_LOG(LogItemInteraction, Warning, TEXT("[ServerReject] Transfer transaction failed."));
return false;
}
UE_LOG(LogItemInteraction, Log, TEXT("[Inventory] transferred %s x%d"), *Item.ItemId.ToString(), Item.Quantity);
return true;
}
// --- Inventory UI operation entry points ---
void UItemInteractionComponent::RequestMoveItem(AActor* StorageActor, FGuid InstanceId, FGuid ToContainerId, int32 ToSlot)
{
Server_MoveItem(StorageActor, InstanceId, ToContainerId, ToSlot);
}
void UItemInteractionComponent::RequestSplitStack(FGuid InstanceId, int32 Amount)
{
Server_SplitStack(InstanceId, Amount);
}
void UItemInteractionComponent::RequestMergeStack(FGuid SourceInstanceId, FGuid TargetInstanceId)
{
Server_MergeStack(SourceInstanceId, TargetInstanceId);
}
void UItemInteractionComponent::RequestDropFromInventory(FGuid InstanceId)
{
Server_DropFromInventory(InstanceId);
}
void UItemInteractionComponent::Server_MoveItem_Implementation(AActor* StorageActor, FGuid InstanceId, FGuid ToContainerId, int32 ToSlot)
{
APawn* Pawn = GetPawn();
UInventoryComponent* PlayerInv = Pawn ? Pawn->FindComponentByClass<UInventoryComponent>() : nullptr;
if (!PlayerInv)
{
return;
}
if (StorageActor)
{
// Cross-inventory transfer; reuse the validated transfer path.
FInteractionContext Ctx = UInteractionResolver::BuildContext(Pawn, StorageActor);
FInteractionRequest Req;
Req.ItemInstanceId = InstanceId;
Req.TargetContainerId = ToContainerId;
ExecuteTransfer(Ctx, Req);
return;
}
// Rearrange within the player's own inventory.
FItemInstanceData Item;
if (PlayerInv->FindItem(InstanceId, Item))
{
PlayerInv->MoveItem(InstanceId, Item.CurrentContainerId, ToContainerId.IsValid() ? ToContainerId : Item.CurrentContainerId, ToSlot);
}
}
void UItemInteractionComponent::Server_SplitStack_Implementation(FGuid InstanceId, int32 Amount)
{
APawn* Pawn = GetPawn();
if (UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass<UInventoryComponent>() : nullptr)
{
Inv->SplitStack(InstanceId, Amount);
}
}
void UItemInteractionComponent::Server_MergeStack_Implementation(FGuid SourceInstanceId, FGuid TargetInstanceId)
{
APawn* Pawn = GetPawn();
if (UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass<UInventoryComponent>() : nullptr)
{
Inv->MergeStack(SourceInstanceId, TargetInstanceId);
}
}
void UItemInteractionComponent::Server_DropFromInventory_Implementation(FGuid InstanceId)
{
HandleDropFromInventory(InstanceId);
}
bool UItemInteractionComponent::HandleDropFromInventory(FGuid InstanceId)
{
// Base cannot spawn a world pickup (that lives in ItemWorld). Overridden there.
UE_LOG(LogItemInteraction, Verbose, TEXT("HandleDropFromInventory: no world handler bound (use WorldInteractionComponent)."));
return false;
}
// --- Timed / hold actions (section 19) ---
void UItemInteractionComponent::BeginTimedAction(const FInteractionContext& Context, const FInteractionOption& Option, const FInteractionRequest& Request)
{
bHasPending = true;
bPendingHold = Option.bRequiresHold;
PendingContext = Context;
PendingOption = Option;
PendingRequest = Request;
const float Duration = Option.ActionDuration > 0.f ? Option.ActionDuration : Option.HoldDuration;
PendingEndTime = GetWorld()->GetTimeSeconds() + FMath::Max(0.05f, Duration);
// Interrupt radius: the interactable reach + tolerance.
float MaxDist = 99999.f;
if (Context.TargetInteractable)
{
MaxDist = Context.TargetInteractable->InteractionDistance + ServerDistanceTolerance;
}
PendingMaxDistanceSq = FMath::Square(MaxDist);
LockActionItem(true);
ActiveAction.bActive = true;
ActiveAction.ActionTag = Option.ActionTag;
ActiveAction.Duration = Duration;
OnActionStarted.Broadcast(Option.ActionTag, Duration); // server-side listeners
SetComponentTickEnabled(true);
}
void UItemInteractionComponent::CompleteTimedAction()
{
const FInteractionOption Option = PendingOption;
const FInteractionContext Context = PendingContext;
const FInteractionRequest Request = PendingRequest;
LockActionItem(false);
bHasPending = false;
ActiveAction = FActiveInteractionState();
SetComponentTickEnabled(false);
const bool bSuccess = ExecuteAuthorized(Context, Option, Request);
OnActionFinished.Broadcast(Option.ActionTag, bSuccess);
Client_InteractionResult(Request.RequestId, bSuccess);
}
void UItemInteractionComponent::AbortTimedAction()
{
if (!bHasPending)
{
return;
}
const FInteractionRequest Request = PendingRequest;
const FGameplayTag Tag = PendingOption.ActionTag;
LockActionItem(false);
bHasPending = false;
ActiveAction = FActiveInteractionState();
SetComponentTickEnabled(false);
OnActionFinished.Broadcast(Tag, false);
Client_InteractionResult(Request.RequestId, false);
UE_LOG(LogItemInteraction, Verbose, TEXT("[Interaction] action %s interrupted."), *Tag.ToString());
}
void UItemInteractionComponent::LockActionItem(bool bLock)
{
APawn* Pawn = GetPawn();
UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass<UInventoryComponent>() : nullptr;
if (!Inv || !PendingRequest.ItemInstanceId.IsValid())
{
return;
}
FItemInstanceData Item;
if (!Inv->FindItem(PendingRequest.ItemInstanceId, Item))
{
return; // item not in player inventory (e.g. held/world) - nothing to lock here
}
if (bLock)
{
Inv->SetItemLock(PendingRequest.ItemInstanceId, ItemEcosystemTags::Lock_Using, GetOwner(), 0.f);
}
else
{
Inv->ClearItemLock(PendingRequest.ItemInstanceId);
}
}
void UItemInteractionComponent::ReleaseHold()
{
Server_ReleaseHold();
}
void UItemInteractionComponent::Server_ReleaseHold_Implementation()
{
if (bHasPending && bPendingHold)
{
AbortTimedAction(); // released before the hold completed
}
}
void UItemInteractionComponent::CancelActiveAction()
{
if (GetOwner() && GetOwner()->HasAuthority())
{
AbortTimedAction();
}
}
void UItemInteractionComponent::OnRep_ActiveAction()
{
if (ActiveAction.bActive)
{
OnActionStarted.Broadcast(ActiveAction.ActionTag, ActiveAction.Duration);
}
else
{
OnActionFinished.Broadcast(ActiveAction.ActionTag, true);
}
}
void UItemInteractionComponent::Client_InteractionResult_Implementation(FGuid RequestId, bool bSuccess)
{
OnInteractionResult.Broadcast(RequestId, bSuccess);
}

View File

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

View File

@ -0,0 +1,48 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GameplayTagContainer.h"
#include "InteractableComponent.generated.h"
class UInteractionAction;
/**
* Marks an actor as interactable and lists the actions it offers (section 8.1).
* Attach to pickups, chests, vehicles, generators, doors, workbenches, corpses,
* placed items, quest objects, etc.
*/
UCLASS(ClassGroup = (Interaction), meta = (BlueprintSpawnableComponent))
class ITEMINTERACTION_API UInteractableComponent : public UActorComponent
{
GENERATED_BODY()
public:
UInteractableComponent();
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
FGameplayTag InteractableType;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
FText DisplayName;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
float InteractionDistance = 250.f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
bool bRequiresLineOfSight = true;
/** Free-form world tags exposed to the interaction context (e.g. Vehicle, Generator). */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
FGameplayTagContainer InteractionTags;
/** Explicit action classes this object offers (in addition to data-driven item rows). */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Instanced, Category = "Interaction")
TArray<TObjectPtr<UInteractionAction>> AvailableActions;
/** Server-side soft claim so two players don't interact with the same object at once. */
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
bool bIsBusy = false;
};

View File

@ -0,0 +1,65 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "UObject/Object.h"
#include "GameplayTagContainer.h"
#include "ItemEnums.h"
#include "InteractionTypes.h"
#include "InteractionAction.generated.h"
/**
* A single interaction verb, authored as an instanced object on an
* InteractableComponent (section 16). This is the Blueprint-extensible path:
* built-in modes are also handled directly by the interaction component, but
* any custom verb can be added here without C++.
*
* CanExecute/Execute must be safe to run on the server. CanExecute may also run
* on the client for predictive UI, so it must not mutate state.
*/
UCLASS(Abstract, Blueprintable, EditInlineNew, DefaultToInstanced, CollapseCategories)
class ITEMINTERACTION_API UInteractionAction : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
FGameplayTag ActionTag;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
FText DisplayText;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
EItemInteractionMode Mode = EItemInteractionMode::Use;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
int32 Priority = 300;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
bool bRequiresHold = false;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
float HoldDuration = 0.f;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
FInteractionExecutionData Execution;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Action")
TArray<FInteractionRequirement> Requirements;
/** Pure check: are all requirements satisfied? Writes the first failure reason. */
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Action")
bool CanExecute(const FInteractionContext& Context, FText& OutDisabledReason) const;
virtual bool CanExecute_Implementation(const FInteractionContext& Context, FText& OutDisabledReason) const;
/** Server-authoritative effect. */
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Action")
void Execute(const FInteractionContext& Context);
virtual void Execute_Implementation(const FInteractionContext& Context);
/** Turns this action into a UI option for the given context. */
FInteractionOption BuildOption(const FInteractionContext& Context) const;
virtual UWorld* GetWorld() const override;
};

View File

@ -0,0 +1,44 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "UObject/Object.h"
#include "InteractionTypes.h"
#include "InteractionResolver.generated.h"
class APawn;
/**
* Central, stateless resolver (section 8.3). Given who is interacting and what
* they look at, it produces the ordered list of available options. Run on the
* client for predictive UI AND re-run on the server to authorize a request, so
* it must be deterministic and side-effect free.
*/
UCLASS()
class ITEMINTERACTION_API UInteractionResolver : public UObject
{
GENERATED_BODY()
public:
/** Assembles the interaction context for an instigator/target pair. */
UFUNCTION(BlueprintCallable, Category = "Interaction", meta = (WorldContext = "Instigator"))
static FInteractionContext BuildContext(APawn* Instigator, AActor* TargetActor);
/** All options available, highest priority first (section 8.6). */
UFUNCTION(BlueprintCallable, Category = "Interaction")
static void GetAvailableInteractions(APawn* Instigator, AActor* TargetActor, TArray<FInteractionOption>& OutOptions);
/** Convenience: the single best (highest priority, enabled) option. */
UFUNCTION(BlueprintCallable, Category = "Interaction")
static bool GetPrimaryInteraction(APawn* Instigator, AActor* TargetActor, FInteractionOption& OutOption);
/** Finds the held item on an instigator via IHeldItemInterface (actor or component). */
static bool ResolveHeldItem(APawn* Instigator, FItemInstanceData& OutItem);
private:
static void GatherCapabilityOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out);
static void GatherContainerOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out);
static void GatherDataDrivenOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out);
static void GatherActionClassOptions(const FInteractionContext& Ctx, TArray<FInteractionOption>& Out);
};

View File

@ -0,0 +1,60 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InteractionTraceComponent.generated.h"
class UInteractableComponent;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInteractionTargetChanged, AActor*, NewTarget, UInteractableComponent*, NewInteractable);
/**
* Local-only aim trace that finds the best interactable under the crosshair
* (section 8.2). Runs only on the owning client; the result drives the prompt UI
* and is what the player confirms - never trusted by the server.
*/
UCLASS(ClassGroup = (Interaction), meta = (BlueprintSpawnableComponent))
class ITEMINTERACTION_API UInteractionTraceComponent : public UActorComponent
{
GENERATED_BODY()
public:
UInteractionTraceComponent();
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
/** Fires the trace once and updates CurrentTarget. */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void TickTrace();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
float TraceDistance = 300.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
float TraceRadius = 12.f;
/** Trace channel; defaults to Visibility. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
TEnumAsByte<ECollisionChannel> TraceChannel = ECC_Visibility;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
TObjectPtr<AActor> CurrentTarget = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
TObjectPtr<UInteractableComponent> CurrentInteractable = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FVector LastHitLocation = FVector::ZeroVector;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FVector LastHitNormal = FVector::ZeroVector;
UPROPERTY(BlueprintAssignable, Category = "Interaction")
FOnInteractionTargetChanged OnTargetChanged;
private:
bool GetViewPoint(FVector& OutLocation, FRotator& OutRotation) const;
void SetTarget(AActor* NewTarget, UInteractableComponent* NewInteractable);
};

View File

@ -0,0 +1,215 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "ItemEnums.h"
#include "ItemInstanceData.h"
#include "InteractionTypes.generated.h"
class APlayerController;
class ACharacter;
class UInteractableComponent;
class UInventoryComponent;
class UTexture2D;
/**
* Client -> server intent (section 9). The client never mutates state; it sends
* this and the server re-validates everything before executing.
*/
USTRUCT(BlueprintType)
struct ITEMINTERACTION_API FInteractionRequest
{
GENERATED_BODY()
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
FGuid RequestId;
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
EItemInteractionMode Mode = EItemInteractionMode::None;
/** The verb that produced this request; lets the server pick the matching action. */
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
FGameplayTag ActionTag;
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
TObjectPtr<AActor> TargetActor = nullptr;
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
FGuid ItemInstanceId;
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
FGuid SourceContainerId;
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
FGuid TargetContainerId;
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
int32 TargetSlotIndex = INDEX_NONE;
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
FGameplayTag TargetSlotTag;
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
FTransform DesiredTransform = FTransform::Identity;
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
int32 Quantity = 1;
/** Last inventory revision the client had; lets the server detect desync. */
UPROPERTY(BlueprintReadWrite, Category = "Interaction")
int32 ClientRevision = 0;
};
/**
* A single interaction the player could perform on the current target (section 8.4).
* Built by the resolver, shown by the UI, and carries the ready-to-send Request.
*/
USTRUCT(BlueprintType)
struct ITEMINTERACTION_API FInteractionOption
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FGameplayTag ActionTag;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
EItemInteractionMode Mode = EItemInteractionMode::None;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FText DisplayText;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
TObjectPtr<UTexture2D> Icon = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
int32 Priority = 0;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
bool bRequiresHold = false;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
float HoldDuration = 0.f;
/** Duration of the action itself once started (section 19). */
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
float ActionDuration = 0.f;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
bool bIsEnabled = true;
/** Why the option is greyed out, e.g. "Inventory Full", "Requires Wrench". */
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FText DisabledReason;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FInteractionRequest Request;
};
/**
* Everything an action/requirement needs to make a decision (section 17).
* Assembled by the resolver on whichever machine is evaluating (client preview
* or server validation).
*/
USTRUCT(BlueprintType)
struct ITEMINTERACTION_API FInteractionContext
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
TObjectPtr<APlayerController> PlayerController = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
TObjectPtr<ACharacter> Character = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
TObjectPtr<AActor> TargetActor = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
TObjectPtr<UInteractableComponent> TargetInteractable = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
TObjectPtr<UInventoryComponent> PlayerInventory = nullptr;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
bool bHasHeldItem = false;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FItemInstanceData HeldItem;
/** The item the target itself represents (for pickups), if any. */
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
bool bHasTargetItem = false;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FItemInstanceData TargetItem;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FGameplayTagContainer WorldTags;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FVector HitLocation = FVector::ZeroVector;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FVector HitNormal = FVector::ZeroVector;
};
/** Requirement type verbs (section 18). */
UENUM(BlueprintType)
enum class EInteractionRequirementType : uint8
{
None,
HasInventorySpace,
HasItemWithTag,
HasHeldItemWithTag,
PropertyGreaterThan,
PropertyLessThan,
TargetHasFreeSlotTag,
NotWet,
NotBroken
};
/** One requirement gate on an interaction (section 18). */
USTRUCT(BlueprintType)
struct ITEMINTERACTION_API FInteractionRequirement
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
EInteractionRequirementType RequirementType = EInteractionRequirementType::None;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
FGameplayTag RequiredTag;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
FName RequiredItemId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
float RequiredValue = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
FText FailureText;
};
/** Timing / interruption data for an action (section 19). */
USTRUCT(BlueprintType)
struct ITEMINTERACTION_API FInteractionExecutionData
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
bool bHasDuration = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
float Duration = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
bool bCanBeInterrupted = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
bool bLockItemDuringAction = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
bool bLockTargetDuringAction = false;
};

View File

@ -0,0 +1,72 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "InteractionAction.h"
#include "ItemInteractionActions.generated.h"
/**
* Concrete interaction actions (section 16). These are the Blueprint-droppable
* authoring units: add one to an InteractableComponent's AvailableActions and the
* resolver/executor will surface and run it. Built-in modes are also dispatched
* directly by the interaction component, so both paths reach the same effect.
*/
UCLASS(meta = (DisplayName = "Action: Instant Pickup"))
class ITEMINTERACTION_API UInteractionAction_InstantPickup : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_InstantPickup();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};
UCLASS(meta = (DisplayName = "Action: Open Loot UI"))
class ITEMINTERACTION_API UInteractionAction_OpenLootUI : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_OpenLootUI();
};
UCLASS(meta = (DisplayName = "Action: Inspect"))
class ITEMINTERACTION_API UInteractionAction_Inspect : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_Inspect();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};
UCLASS(meta = (DisplayName = "Action: Combine"))
class ITEMINTERACTION_API UInteractionAction_Combine : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_Combine();
};
UCLASS(meta = (DisplayName = "Action: Transfer Container Item"))
class ITEMINTERACTION_API UInteractionAction_TransferContainerItem : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_TransferContainerItem();
};
UCLASS(meta = (DisplayName = "Action: Split Stack"))
class ITEMINTERACTION_API UInteractionAction_SplitStack : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_SplitStack();
};
UCLASS(meta = (DisplayName = "Action: Merge Stack"))
class ITEMINTERACTION_API UInteractionAction_MergeStack : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_MergeStack();
};

View File

@ -0,0 +1,178 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InteractionTypes.h"
#include "ItemInteractionComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInteractionResult, FGuid, RequestId, bool, bSuccess);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnOpenContainer, AActor*, ContainerActor);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnActionStarted, FGameplayTag, ActionTag, float, Duration);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnActionFinished, FGameplayTag, ActionTag, bool, bCompleted);
/** Replicated snapshot of a long/hold action in progress (section 19), for UI. */
USTRUCT(BlueprintType)
struct FActiveInteractionState
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
bool bActive = false;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
FGameplayTag ActionTag;
UPROPERTY(BlueprintReadOnly, Category = "Interaction")
float Duration = 0.f;
};
/**
* Player-side hub that turns a chosen option into a server request and executes
* the authorized result (sections 9, 25). Attach to the player pawn alongside
* an InventoryComponent and (optionally) an InteractionTraceComponent.
*
* Flow: UI/input picks an FInteractionOption -> RequestInteraction() -> Server RPC
* -> server re-resolves and re-validates -> executes the transaction -> state
* replicates back. The client never changes item state itself.
*/
UCLASS(ClassGroup = (Interaction), meta = (BlueprintSpawnableComponent))
class ITEMINTERACTION_API UItemInteractionComponent : public UActorComponent
{
GENERATED_BODY()
public:
UItemInteractionComponent();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
/** Client entry point: send the chosen option's request to the server. */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void RequestInteraction(const FInteractionOption& Option);
/** Client: signal that a hold action's button was released (cancels if mid-action). */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void ReleaseHold();
/** Server: cancel any action in progress (bind to damage/stun for interruption). */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void CancelActiveAction();
UFUNCTION(BlueprintPure, Category = "Interaction")
const FActiveInteractionState& GetActiveAction() const { return ActiveAction; }
/** Lower-level entry: send a hand-built request. */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void SendRequest(const FInteractionRequest& Request);
// --- Inventory UI operations (section 23.3). Client calls, server validates. ---
/**
* Move/transfer an item. StorageActor == null -> rearrange within the player's
* own inventory. Otherwise transfer between the player and the storage actor's
* inventory (loot). ToContainerId == invalid -> first accepting container.
*/
UFUNCTION(BlueprintCallable, Category = "Inventory")
void RequestMoveItem(AActor* StorageActor, FGuid InstanceId, FGuid ToContainerId, int32 ToSlot);
UFUNCTION(BlueprintCallable, Category = "Inventory")
void RequestSplitStack(FGuid InstanceId, int32 Amount);
UFUNCTION(BlueprintCallable, Category = "Inventory")
void RequestMergeStack(FGuid SourceInstanceId, FGuid TargetInstanceId);
UFUNCTION(BlueprintCallable, Category = "Inventory")
void RequestDropFromInventory(FGuid InstanceId);
UPROPERTY(BlueprintAssignable, Category = "Interaction")
FOnInteractionResult OnInteractionResult;
/** Fired locally when the player chooses to open a container - UI opens the loot window. */
UPROPERTY(BlueprintAssignable, Category = "Interaction")
FOnOpenContainer OnOpenContainer;
UPROPERTY(BlueprintAssignable, Category = "Interaction")
FOnActionStarted OnActionStarted;
UPROPERTY(BlueprintAssignable, Category = "Interaction")
FOnActionFinished OnActionFinished;
/** Max slack (cm) added to the interactable's own distance when server-validating. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction")
float ServerDistanceTolerance = 75.f;
protected:
UFUNCTION(Server, Reliable)
void Server_RequestInteraction(const FInteractionRequest& Request);
UFUNCTION(Server, Reliable)
void Server_MoveItem(AActor* StorageActor, FGuid InstanceId, FGuid ToContainerId, int32 ToSlot);
UFUNCTION(Server, Reliable)
void Server_SplitStack(FGuid InstanceId, int32 Amount);
UFUNCTION(Server, Reliable)
void Server_MergeStack(FGuid SourceInstanceId, FGuid TargetInstanceId);
UFUNCTION(Server, Reliable)
void Server_DropFromInventory(FGuid InstanceId);
UFUNCTION(Server, Reliable)
void Server_ReleaseHold();
UFUNCTION(Client, Reliable)
void Client_InteractionResult(FGuid RequestId, bool bSuccess);
/** Server hook for dropping an item into the world. Overridden in ItemWorld. */
virtual bool HandleDropFromInventory(FGuid InstanceId);
private:
/** Server: full validation pipeline (section 9). Returns the authorized option. */
bool AuthorizeRequest(const FInteractionRequest& Request, FInteractionContext& OutContext, FInteractionOption& OutOption) const;
public:
/**
* Server: run a mode directly from an already-built context (used by the named
* UInteractionAction_* classes so they share the one execution path). Builds a
* minimal option/request and dispatches through ExecuteAuthorized (virtual, so
* the ItemWorld subclass handles carry/equip/install/use).
*/
bool ServerExecuteMode(const FInteractionContext& Context, EItemInteractionMode Mode, FGameplayTag ActionTag);
protected:
APawn* GetPawn() const;
/**
* Server: run the authorized option's effect. Virtual so ItemWorld can add the
* carry/drop/equip/install/use-on-target modes (which need ItemWorld types)
* without ItemInteraction depending on ItemWorld. Base handles pickup + transfer.
*/
virtual bool ExecuteAuthorized(const FInteractionContext& Context, const FInteractionOption& Option, const FInteractionRequest& Request);
bool ExecutePickup(const FInteractionContext& Context);
bool ExecuteTransfer(const FInteractionContext& Context, const FInteractionRequest& Request);
bool ExecuteActionClass(const FInteractionContext& Context, const FInteractionOption& Option);
// --- Timed / hold action plumbing (section 19) ---
void BeginTimedAction(const FInteractionContext& Context, const FInteractionOption& Option, const FInteractionRequest& Request);
void CompleteTimedAction();
void AbortTimedAction();
void LockActionItem(bool bLock);
UPROPERTY(ReplicatedUsing = OnRep_ActiveAction)
FActiveInteractionState ActiveAction;
UFUNCTION()
void OnRep_ActiveAction();
// Server-side pending state.
bool bHasPending = false;
bool bPendingHold = false;
float PendingEndTime = 0.f;
float PendingMaxDistanceSq = 0.f;
FInteractionContext PendingContext;
FInteractionOption PendingOption;
FInteractionRequest PendingRequest;
};

View File

@ -0,0 +1,84 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "ItemInstanceData.h"
#include "ItemInteractionInterfaces.generated.h"
/**
* Implemented by world actors that represent a concrete item (e.g. APickupItemActor).
* Lets the interaction layer read/take the item without ItemInteraction depending on
* ItemWorld - the dependency only flows World -> Interaction.
*/
UINTERFACE(MinimalAPI, BlueprintType)
class UWorldItemProvider : public UInterface
{
GENERATED_BODY()
};
class ITEMINTERACTION_API IWorldItemProvider
{
GENERATED_BODY()
public:
/** Returns the item this actor carries. */
virtual bool GetProvidedItem(FItemInstanceData& OutItem) const = 0;
/** True if the provider is currently locked/claimed by another interaction. */
virtual bool IsItemAvailable() const { return true; }
/** Server: the item was taken into an inventory/hands - tear down the world actor. */
virtual void NotifyItemTaken(AActor* Taker) {}
/** Server: only part of the stack was taken; write back the remaining item. */
virtual void UpdateProvidedItem(const FItemInstanceData& RemainingItem) {}
/** Server: mark this world item as being physically carried by Holder (or released). */
virtual void SetCarried(AActor* Holder, bool bCarried) {}
};
/**
* Implemented by whatever tracks the pawn's currently held/carried item
* (the character or its UHeldItemComponent). The resolver queries it to build
* "held + target" interactions without depending on ItemWorld.
*/
UINTERFACE(MinimalAPI, BlueprintType)
class UHeldItemInterface : public UInterface
{
GENERATED_BODY()
};
class ITEMINTERACTION_API IHeldItemInterface
{
GENERATED_BODY()
public:
virtual bool GetHeldItem(FItemInstanceData& OutItem) const = 0;
virtual bool HasHeldItem() const = 0;
};
/**
* Implemented by world objects that react to a held item being used on them
* (section 15): pour fuel into a generator, barricade a door, place bait, etc.
* Found on the target actor or any of its components.
*/
UINTERFACE(MinimalAPI, BlueprintType)
class UItemUseTarget : public UInterface
{
GENERATED_BODY()
};
class ITEMINTERACTION_API IItemUseTarget
{
GENERATED_BODY()
public:
/**
* Server: apply the held item to this target for the given result verb (e.g.
* "TransferFuel"). May mutate HeldItem (reduce fuel/charge). Returns true if it
* did something - the caller then writes HeldItem back to the held component.
*/
virtual bool ReceiveHeldUse(FName ResultVerb, FItemInstanceData& HeldItem, AActor* Instigator) = 0;
};

View File

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

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
};
};

View File

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

View File

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

View File

@ -0,0 +1,53 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "PlacementInteractionActions.h"
#include "PlacementPreviewComponent.h"
#include "ItemNativeTags.h"
#include "GameFramework/Character.h"
namespace
{
UPlacementPreviewComponent* GetPlacement(const FInteractionContext& Context)
{
return Context.Character ? Context.Character->FindComponentByClass<UPlacementPreviewComponent>() : nullptr;
}
}
UInteractionAction_StartPlacement::UInteractionAction_StartPlacement()
{
ActionTag = ItemEcosystemTags::Action_Place;
Mode = EItemInteractionMode::StartPlacement;
Priority = 300;
DisplayText = NSLOCTEXT("ItemPlacement", "Place", "Place");
}
void UInteractionAction_StartPlacement::Execute_Implementation(const FInteractionContext& Context)
{
if (UPlacementPreviewComponent* P = GetPlacement(Context))
{
const FGuid Id = Context.bHasTargetItem ? Context.TargetItem.InstanceId
: (Context.bHasHeldItem ? Context.HeldItem.InstanceId : FGuid());
if (Id.IsValid()) { P->StartPlacement(Id); }
}
}
UInteractionAction_ConfirmPlacement::UInteractionAction_ConfirmPlacement()
{
Mode = EItemInteractionMode::ConfirmPlacement;
Priority = 1000;
DisplayText = NSLOCTEXT("ItemPlacement", "Confirm", "Confirm");
}
void UInteractionAction_ConfirmPlacement::Execute_Implementation(const FInteractionContext& Context)
{
if (UPlacementPreviewComponent* P = GetPlacement(Context)) { P->ConfirmPlacement(); }
}
UInteractionAction_CancelPlacement::UInteractionAction_CancelPlacement()
{
Mode = EItemInteractionMode::CancelPlacement;
Priority = 1000;
DisplayText = NSLOCTEXT("ItemPlacement", "Cancel", "Cancel");
}
void UInteractionAction_CancelPlacement::Execute_Implementation(const FInteractionContext& Context)
{
if (UPlacementPreviewComponent* P = GetPlacement(Context)) { P->CancelPlacement(); }
}

View File

@ -0,0 +1,282 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#include "PlacementPreviewComponent.h"
#include "ItemPlacementLog.h"
#include "ItemDatabaseSubsystem.h"
#include "ItemDefinitionRow.h"
#include "InventoryComponent.h"
#include "PickupItemActor.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/PlayerController.h"
UPlacementPreviewComponent::UPlacementPreviewComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.bStartWithTickEnabled = false;
SetIsReplicatedByDefault(true); // for the server confirm RPC
}
bool UPlacementPreviewComponent::GetViewPoint(FVector& OutLoc, FRotator& OutRot) const
{
const APawn* Pawn = Cast<APawn>(GetOwner());
const APlayerController* PC = Pawn ? Cast<APlayerController>(Pawn->GetController()) : nullptr;
if (!PC || !PC->IsLocalController())
{
return false;
}
PC->GetPlayerViewPoint(OutLoc, OutRot);
return true;
}
void UPlacementPreviewComponent::StartPlacement(FGuid InstanceId)
{
APawn* Pawn = Cast<APawn>(GetOwner());
UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass<UInventoryComponent>() : nullptr;
if (!Inv)
{
return;
}
FItemInstanceData Item;
if (!Inv->FindItem(InstanceId, Item))
{
return;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(Item.ItemId) : nullptr;
if (!Def || !Def->bCanPlace)
{
return;
}
ItemInstanceId = InstanceId;
ItemId = Item.ItemId;
bIsPlacing = true;
PreviewYaw = 0.f;
// Build the local ghost mesh.
if (!Ghost)
{
Ghost = NewObject<UStaticMeshComponent>(GetOwner(), TEXT("PlacementGhost"));
Ghost->SetupAttachment(GetOwner()->GetRootComponent());
Ghost->SetCollisionEnabled(ECollisionEnabled::NoCollision);
Ghost->SetCastShadow(false);
Ghost->RegisterComponent();
}
if (!Def->WorldMesh.IsNull())
{
if (UStaticMesh* M = Def->WorldMesh.LoadSynchronous())
{
Ghost->SetStaticMesh(M);
}
}
Ghost->SetVisibility(true);
SetComponentTickEnabled(true);
}
void UPlacementPreviewComponent::CancelPlacement()
{
bIsPlacing = false;
bCurrentValid = false;
SetComponentTickEnabled(false);
DestroyGhost();
}
void UPlacementPreviewComponent::RotatePreview(float DeltaYawDegrees)
{
PreviewYaw = FMath::Fmod(PreviewYaw + DeltaYawDegrees, 360.f);
}
void UPlacementPreviewComponent::ConfirmPlacement()
{
if (!bIsPlacing || !bCurrentValid)
{
return;
}
Server_ConfirmPlacement(ItemInstanceId, PreviewTransform);
CancelPlacement();
}
void UPlacementPreviewComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (!bIsPlacing)
{
return;
}
FTransform Xf;
if (ComputePreviewTransform(Xf))
{
PreviewTransform = Xf;
if (Ghost)
{
Ghost->SetWorldTransform(Xf);
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(ItemId) : nullptr;
FString Reason;
const bool bValid = Def && ValidatePlacement(this, *Def, Xf, GetOwner(), Reason);
SetValidity(bValid);
}
else
{
SetValidity(false);
}
}
bool UPlacementPreviewComponent::ComputePreviewTransform(FTransform& OutTransform) const
{
FVector ViewLoc;
FRotator ViewRot;
if (!GetViewPoint(ViewLoc, ViewRot))
{
return false;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(ItemId) : nullptr;
const float MaxDist = Def ? Def->PlacementRules.MaxDistance : 400.f;
const FVector Start = ViewLoc;
const FVector End = ViewLoc + ViewRot.Vector() * MaxDist;
FCollisionQueryParams Params(SCENE_QUERY_STAT(PlacementTrace), false, GetOwner());
FHitResult Hit;
if (!GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Visibility, Params))
{
return false;
}
FRotator Rot(0.f, PreviewYaw, 0.f);
// Optionally align to surface normal when not requiring a flat surface.
if (Def && !Def->PlacementRules.bRequiresFlatSurface)
{
const FRotator Aligned = FRotationMatrix::MakeFromZX(Hit.ImpactNormal, ViewRot.Vector()).Rotator();
Rot = FRotator(Aligned.Pitch, PreviewYaw, Aligned.Roll);
}
OutTransform = FTransform(Rot, Hit.ImpactPoint);
return true;
}
bool UPlacementPreviewComponent::ValidatePlacement(const UObject* WorldContext, const FItemDefinitionRow& Def,
const FTransform& Transform, AActor* Instigator, FString& OutReason)
{
const FItemPlacementRules& Rules = Def.PlacementRules;
UWorld* World = GEngine ? GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::ReturnNull) : nullptr;
if (!World)
{
OutReason = TEXT("No world");
return false;
}
// Distance from the instigator.
if (Instigator)
{
const float DistSq = FVector::DistSquared(Instigator->GetActorLocation(), Transform.GetLocation());
if (DistSq > FMath::Square(Rules.MaxDistance + 100.f))
{
OutReason = TEXT("Too far");
return false;
}
}
// Slope: trace down and measure the surface normal.
{
const FVector Loc = Transform.GetLocation();
FCollisionQueryParams Params(SCENE_QUERY_STAT(PlacementSlope), false, Instigator);
FHitResult Hit;
if (World->LineTraceSingleByChannel(Hit, Loc + FVector(0, 0, 50.f), Loc - FVector(0, 0, 100.f), ECC_Visibility, Params))
{
const float SlopeDeg = FMath::RadiansToDegrees(FMath::Acos(FVector::DotProduct(Hit.ImpactNormal, FVector::UpVector)));
if (Rules.bCanPlaceOnGround && SlopeDeg > Rules.MaxSlopeAngle)
{
OutReason = TEXT("Surface too steep");
return false;
}
}
else if (Rules.bRequiresFlatSurface)
{
OutReason = TEXT("No surface");
return false;
}
}
// Collision: overlap a box at the target; any blocking world geometry rejects.
{
const FVector Extent(40.f, 40.f, 40.f);
FCollisionQueryParams Params(SCENE_QUERY_STAT(PlacementOverlap), false, Instigator);
FCollisionShape Box = FCollisionShape::MakeBox(Extent);
if (World->OverlapBlockingTestByChannel(Transform.GetLocation() + FVector(0, 0, Extent.Z), FQuat::Identity, ECC_WorldStatic, Box, Params))
{
OutReason = TEXT("Blocked");
return false;
}
}
OutReason.Reset();
return true;
}
void UPlacementPreviewComponent::Server_ConfirmPlacement_Implementation(FGuid InstanceId, FTransform Transform)
{
APawn* Pawn = Cast<APawn>(GetOwner());
UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass<UInventoryComponent>() : nullptr;
if (!Inv)
{
return;
}
FItemInstanceData Item;
if (!Inv->FindItem(InstanceId, Item) || Item.Lock.bLocked)
{
return;
}
const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(this);
const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(Item.ItemId) : nullptr;
if (!Def || !Def->bCanPlace)
{
return;
}
FString Reason;
if (!ValidatePlacement(this, *Def, Transform, GetOwner(), Reason))
{
UE_LOG(LogItemPlacement, Warning, TEXT("[ServerReject] Placement failed: %s"), *Reason);
return;
}
if (APickupItemActor::SpawnPickupFromInstance(this, Item, Transform))
{
Inv->RemoveItem(InstanceId);
UE_LOG(LogItemPlacement, Log, TEXT("[Placement] placed %s"), *Item.ItemId.ToString());
}
}
void UPlacementPreviewComponent::SetValidity(bool bNewValid)
{
if (bCurrentValid != bNewValid)
{
bCurrentValid = bNewValid;
OnPlacementStateChanged.Broadcast(bNewValid);
}
}
void UPlacementPreviewComponent::DestroyGhost()
{
if (Ghost)
{
Ghost->DestroyComponent();
Ghost = nullptr;
}
}
void UPlacementPreviewComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
DestroyGhost();
Super::EndPlay(EndPlayReason);
}

View File

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

View File

@ -0,0 +1,36 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "InteractionAction.h"
#include "PlacementInteractionActions.generated.h"
/** Placement actions (section 16) - drive the player's UPlacementPreviewComponent. */
UCLASS(meta = (DisplayName = "Action: Start Placement"))
class ITEMPLACEMENT_API UInteractionAction_StartPlacement : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_StartPlacement();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};
UCLASS(meta = (DisplayName = "Action: Confirm Placement"))
class ITEMPLACEMENT_API UInteractionAction_ConfirmPlacement : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_ConfirmPlacement();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};
UCLASS(meta = (DisplayName = "Action: Cancel Placement"))
class ITEMPLACEMENT_API UInteractionAction_CancelPlacement : public UInteractionAction
{
GENERATED_BODY()
public:
UInteractionAction_CancelPlacement();
virtual void Execute_Implementation(const FInteractionContext& Context) override;
};

View File

@ -0,0 +1,82 @@
// Copyright ExByte Studios. Item Interaction Ecosystem.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "ItemPlacementRules.h"
#include "PlacementPreviewComponent.generated.h"
class UStaticMeshComponent;
struct FItemDefinitionRow;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnPlacementStateChanged, bool, bIsValid);
/**
* Drives the local ghost-mesh preview and the server-validated confirm for
* placeable items (section 13). The client shows a green/red ghost; the server
* re-runs every rule before committing (collision, slope, distance, surface).
*/
UCLASS(ClassGroup = (Placement), meta = (BlueprintSpawnableComponent))
class ITEMPLACEMENT_API UPlacementPreviewComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPlacementPreviewComponent();
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
UPROPERTY(BlueprintReadOnly, Category = "Placement")
bool bIsPlacing = false;
UPROPERTY(BlueprintReadOnly, Category = "Placement")
FName ItemId;
UPROPERTY(BlueprintReadOnly, Category = "Placement")
FGuid ItemInstanceId;
UPROPERTY(BlueprintReadOnly, Category = "Placement")
FTransform PreviewTransform = FTransform::Identity;
UPROPERTY(BlueprintReadOnly, Category = "Placement")
bool bCurrentValid = false;
UPROPERTY(BlueprintAssignable, Category = "Placement")
FOnPlacementStateChanged OnPlacementStateChanged;
/** Client: begin placing an inventory item. */
UFUNCTION(BlueprintCallable, Category = "Placement")
void StartPlacement(FGuid InstanceId);
/** Client: confirm at the current preview transform (sends a server request). */
UFUNCTION(BlueprintCallable, Category = "Placement")
void ConfirmPlacement();
/** Client: abort placement. */
UFUNCTION(BlueprintCallable, Category = "Placement")
void CancelPlacement();
UFUNCTION(BlueprintCallable, Category = "Placement")
void RotatePreview(float DeltaYawDegrees);
/** Pure rule check used by both the client preview and the server (section 13.3). */
static bool ValidatePlacement(const UObject* WorldContext, const FItemDefinitionRow& Def,
const FTransform& Transform, AActor* Instigator, FString& OutReason);
protected:
UFUNCTION(Server, Reliable)
void Server_ConfirmPlacement(FGuid InstanceId, FTransform Transform);
private:
UPROPERTY(Transient)
TObjectPtr<UStaticMeshComponent> Ghost = nullptr;
float PreviewYaw = 0.f;
bool ComputePreviewTransform(FTransform& OutTransform) const;
void DestroyGhost();
void SetValidity(bool bNewValid);
bool GetViewPoint(FVector& OutLoc, FRotator& OutRot) const;
};

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;
};