commit 7f7e043a888dcd1518a79333e7a5d6f544fe87ac Author: Bonchellon <64547270+bonchellon@users.noreply.github.com> Date: Mon Jun 22 20:49:56 2026 +0300 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723b04f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Docs/ExampleData/DT_ItemFeatures.csv b/Docs/ExampleData/DT_ItemFeatures.csv new file mode 100644 index 0000000..8fc16c6 --- /dev/null +++ b/Docs/ExampleData/DT_ItemFeatures.csv @@ -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 diff --git a/Docs/ExampleData/DT_ItemInteractions.csv b/Docs/ExampleData/DT_ItemInteractions.csv new file mode 100644 index 0000000..2562632 --- /dev/null +++ b/Docs/ExampleData/DT_ItemInteractions.csv @@ -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 diff --git a/Docs/ExampleData/DT_ItemProperties.csv b/Docs/ExampleData/DT_ItemProperties.csv new file mode 100644 index 0000000..04caaf6 --- /dev/null +++ b/Docs/ExampleData/DT_ItemProperties.csv @@ -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 diff --git a/Docs/ExampleData/DT_ItemRedirects.csv b/Docs/ExampleData/DT_ItemRedirects.csv new file mode 100644 index 0000000..21e1d01 --- /dev/null +++ b/Docs/ExampleData/DT_ItemRedirects.csv @@ -0,0 +1,3 @@ +Name,OldItemId,NewItemId +redirect_medkit,medkit,medkit_small +redirect_flashlight,old_flashlight,flashlight diff --git a/Docs/ExampleData/DT_Items.csv b/Docs/ExampleData/DT_Items.csv new file mode 100644 index 0000000..679db2d --- /dev/null +++ b/Docs/ExampleData/DT_Items.csv @@ -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,,,,, diff --git a/ItemEcosystem.uplugin b/ItemEcosystem.uplugin new file mode 100644 index 0000000..165d581 --- /dev/null +++ b/ItemEcosystem.uplugin @@ -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 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..39e8073 --- /dev/null +++ b/README.md @@ -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 +> 1–11). 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 [Qty] Give to the local player inventory +ItemEco.Spawn [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 + +``` +"\Engine\Build\BatchFiles\Build.bat" InventorySystemEditor Win64 Development ^ + -Project="\InventorySystem.uproject" -WaitMutex +``` + +Then in-editor: Tools → Item Ecosystem → **Validate Item Data**, or `ItemEco.Validate` +in PIE. diff --git a/Source/ItemCore/ItemCore.Build.cs b/Source/ItemCore/ItemCore.Build.cs new file mode 100644 index 0000000..55110bb --- /dev/null +++ b/Source/ItemCore/ItemCore.Build.cs @@ -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[] + { + }); + } +} diff --git a/Source/ItemCore/Private/ItemCoreModule.cpp b/Source/ItemCore/Private/ItemCoreModule.cpp new file mode 100644 index 0000000..9e5cb81 --- /dev/null +++ b/Source/ItemCore/Private/ItemCoreModule.cpp @@ -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) diff --git a/Source/ItemCore/Private/ItemNativeTags.cpp b/Source/ItemCore/Private/ItemNativeTags.cpp new file mode 100644 index 0000000..ea417f3 --- /dev/null +++ b/Source/ItemCore/Private/ItemNativeTags.cpp @@ -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"); +} diff --git a/Source/ItemCore/Private/ItemRuntimeProperties.cpp b/Source/ItemCore/Private/ItemRuntimeProperties.cpp new file mode 100644 index 0000000..2973610 --- /dev/null +++ b/Source/ItemCore/Private/ItemRuntimeProperties.cpp @@ -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" diff --git a/Source/ItemCore/Public/ItemCoreLog.h b/Source/ItemCore/Public/ItemCoreLog.h new file mode 100644 index 0000000..588f5e8 --- /dev/null +++ b/Source/ItemCore/Public/ItemCoreLog.h @@ -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); diff --git a/Source/ItemCore/Public/ItemCoreModule.h b/Source/ItemCore/Public/ItemCoreModule.h new file mode 100644 index 0000000..3d8e363 --- /dev/null +++ b/Source/ItemCore/Public/ItemCoreModule.h @@ -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; +}; diff --git a/Source/ItemCore/Public/ItemEnums.h b/Source/ItemCore/Public/ItemEnums.h new file mode 100644 index 0000000..04de678 --- /dev/null +++ b/Source/ItemCore/Public/ItemEnums.h @@ -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 +}; diff --git a/Source/ItemCore/Public/ItemInstanceData.h b/Source/ItemCore/Public/ItemInstanceData.h new file mode 100644 index 0000000..9d16246 --- /dev/null +++ b/Source/ItemCore/Public/ItemInstanceData.h @@ -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; } +}; diff --git a/Source/ItemCore/Public/ItemLockData.h b/Source/ItemCore/Public/ItemLockData.h new file mode 100644 index 0000000..60b9287 --- /dev/null +++ b/Source/ItemCore/Public/ItemLockData.h @@ -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 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; + } +}; diff --git a/Source/ItemCore/Public/ItemNativeTags.h b/Source/ItemCore/Public/ItemNativeTags.h new file mode 100644 index 0000000..3e984b2 --- /dev/null +++ b/Source/ItemCore/Public/ItemNativeTags.h @@ -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); +} diff --git a/Source/ItemCore/Public/ItemRuntimeProperties.h b/Source/ItemCore/Public/ItemRuntimeProperties.h new file mode 100644 index 0000000..ec28065 --- /dev/null +++ b/Source/ItemCore/Public/ItemRuntimeProperties.h @@ -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 FloatProperties; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime") + TArray IntProperties; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime") + TArray BoolProperties; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, Category = "Item|Runtime") + TArray 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 + static bool ArrayEqual(const TArray& A, const TArray& B) + { + if (A.Num() != B.Num()) { return false; } + for (const T& Elem : A) { if (!B.Contains(Elem)) { return false; } } + return true; + } +}; diff --git a/Source/ItemDatabase/ItemDatabase.Build.cs b/Source/ItemDatabase/ItemDatabase.Build.cs new file mode 100644 index 0000000..ad662d1 --- /dev/null +++ b/Source/ItemDatabase/ItemDatabase.Build.cs @@ -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" + }); + } +} diff --git a/Source/ItemDatabase/Private/ItemDatabaseModule.cpp b/Source/ItemDatabase/Private/ItemDatabaseModule.cpp new file mode 100644 index 0000000..01331be --- /dev/null +++ b/Source/ItemDatabase/Private/ItemDatabaseModule.cpp @@ -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) diff --git a/Source/ItemDatabase/Private/ItemDatabaseSubsystem.cpp b/Source/ItemDatabase/Private/ItemDatabaseSubsystem.cpp new file mode 100644 index 0000000..4cc9c32 --- /dev/null +++ b/Source/ItemDatabase/Private/ItemDatabaseSubsystem.cpp @@ -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 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(); + } + } + 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 Rows; + Table->GetAllRows(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 Rows; + Table->GetAllRows(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 Rows; + Table->GetAllRows(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 Rows; + Table->GetAllRows(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 Rows; + Table->GetAllRows(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 Rows; + Table->GetAllRows(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 Rows; + Table->GetAllRows(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 Rows; + Table->GetAllRows(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 Rows; + Table->GetAllRows(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* 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& Out) const +{ + if (const TArray* 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& OutRecipes) const +{ + RecipesById.GenerateValueArray(OutRecipes); +} + +void UItemDatabaseSubsystem::GetLootEntries(FName TableId, TArray& Out) const +{ + if (const TArray* Found = LootByTable.Find(TableId)) { Out = *Found; } +} + +void UItemDatabaseSubsystem::RollLoot(FName TableId, int32 RollCount, TArray& OutItems) const +{ + const TArray* 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& 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* 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* 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& Out) const +{ + if (const TArray* Rows = PropertiesByItem.Find(ResolveRedirect(ItemId))) + { + Out = *Rows; + } +} + +void UItemDatabaseSubsystem::GetFeatureRows(FName ItemId, TArray& Out) const +{ + if (const TArray* Rows = FeaturesByItem.Find(ResolveRedirect(ItemId))) + { + Out = *Rows; + } +} + +void UItemDatabaseSubsystem::GetFeatureRows(FName ItemId, const FGameplayTag& Feature, TArray& Out) const +{ + if (const TArray* Rows = FeaturesByItem.Find(ResolveRedirect(ItemId))) + { + for (const FItemFeatureRow& Row : *Rows) + { + if (Row.Feature == Feature) + { + Out.Add(Row); + } + } + } +} + +void UItemDatabaseSubsystem::GetInteractionRows(FName ItemId, TArray& Out) const +{ + if (const TArray* Rows = InteractionsByItem.Find(ResolveRedirect(ItemId))) + { + Out = *Rows; + } +} + +void UItemDatabaseSubsystem::ValidateData(TArray& 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& 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* 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* 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& 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); + } + } +} diff --git a/Source/ItemDatabase/Private/ItemEcosystemSettings.cpp b/Source/ItemDatabase/Private/ItemEcosystemSettings.cpp new file mode 100644 index 0000000..2551a14 --- /dev/null +++ b/Source/ItemDatabase/Private/ItemEcosystemSettings.cpp @@ -0,0 +1,13 @@ +// Copyright ExByte Studios. Item Interaction Ecosystem. + +#include "ItemEcosystemSettings.h" + +UItemEcosystemSettings::UItemEcosystemSettings() +{ + CategoryName = FName("Game"); +} + +const UItemEcosystemSettings* UItemEcosystemSettings::Get() +{ + return GetDefault(); +} diff --git a/Source/ItemDatabase/Public/ItemAuxiliaryRows.h b/Source/ItemDatabase/Public/ItemAuxiliaryRows.h new file mode 100644 index 0000000..c15571b --- /dev/null +++ b/Source/ItemDatabase/Public/ItemAuxiliaryRows.h @@ -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; +}; diff --git a/Source/ItemDatabase/Public/ItemContentRows.h b/Source/ItemDatabase/Public/ItemContentRows.h new file mode 100644 index 0000000..98e4df4 --- /dev/null +++ b/Source/ItemDatabase/Public/ItemContentRows.h @@ -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 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; +}; diff --git a/Source/ItemDatabase/Public/ItemDatabaseLog.h b/Source/ItemDatabase/Public/ItemDatabaseLog.h new file mode 100644 index 0000000..ed7eeff --- /dev/null +++ b/Source/ItemDatabase/Public/ItemDatabaseLog.h @@ -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); diff --git a/Source/ItemDatabase/Public/ItemDatabaseSubsystem.h b/Source/ItemDatabase/Public/ItemDatabaseSubsystem.h new file mode 100644 index 0000000..a3cf045 --- /dev/null +++ b/Source/ItemDatabase/Public/ItemDatabaseSubsystem.h @@ -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& 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& Out) const; + void GetFeatureRows(FName ItemId, TArray& 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& Out) const; + + void GetInteractionRows(FName ItemId, TArray& 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& 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& OutRecipes) const; + + void GetLootEntries(FName TableId, TArray& 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& OutItems) const; + + // --- Validation (section 28) --- + + UFUNCTION(BlueprintCallable, Category = "Item|Database") + void ValidateData(TArray& 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 Definitions; + + /** ItemId -> default property rows. */ + TMap> PropertiesByItem; + + /** ItemId -> feature parameter rows. */ + TMap> FeaturesByItem; + + /** ItemId -> interaction rows. */ + TMap> InteractionsByItem; + + /** Old -> new id. */ + TMap Redirects; + + TMap ContainerDefs; + TMap WorldSlotDefs; + TMap> LootByTable; + TMap RecipesById; + + bool bCacheBuilt = false; +}; diff --git a/Source/ItemDatabase/Public/ItemDefinitionRow.h b/Source/ItemDatabase/Public/ItemDefinitionRow.h new file mode 100644 index 0000000..ed11c3c --- /dev/null +++ b/Source/ItemDatabase/Public/ItemDefinitionRow.h @@ -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 Icon; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Visual") + TSoftObjectPtr WorldMesh; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Visual") + TSoftClassPtr PickupActorClass; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Visual") + TSoftClassPtr 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); } +}; diff --git a/Source/ItemDatabase/Public/ItemEcosystemSettings.h b/Source/ItemDatabase/Public/ItemEcosystemSettings.h new file mode 100644 index 0000000..e681750 --- /dev/null +++ b/Source/ItemDatabase/Public/ItemEcosystemSettings.h @@ -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 ItemsTable; + + /** DT_ItemProperties - row struct FItemPropertyRow. */ + UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable")) + TSoftObjectPtr PropertiesTable; + + /** DT_ItemFeatures - row struct FItemFeatureRow. */ + UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable")) + TSoftObjectPtr FeaturesTable; + + /** DT_ItemInteractions - row struct FItemInteractionRow. */ + UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable")) + TSoftObjectPtr InteractionsTable; + + /** DT_ItemRedirects - row struct FItemRedirectRow. */ + UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable")) + TSoftObjectPtr RedirectsTable; + + /** DT_Containers - row struct FContainerDefRow. */ + UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable")) + TSoftObjectPtr ContainersTable; + + /** DT_WorldSlots - row struct FWorldSlotDefRow. */ + UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable")) + TSoftObjectPtr WorldSlotsTable; + + /** DT_LootTables - row struct FLootEntryRow. */ + UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable")) + TSoftObjectPtr LootTable; + + /** DT_CraftingRecipes - row struct FCraftingRecipeRow. */ + UPROPERTY(Config, EditAnywhere, Category = "Data Tables", meta = (AllowedClasses = "/Script/Engine.DataTable")) + TSoftObjectPtr RecipesTable; + + static const UItemEcosystemSettings* Get(); +}; diff --git a/Source/ItemDatabase/Public/ItemPlacementRules.h b/Source/ItemDatabase/Public/ItemPlacementRules.h new file mode 100644 index 0000000..0afd40e --- /dev/null +++ b/Source/ItemDatabase/Public/ItemPlacementRules.h @@ -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; +}; diff --git a/Source/ItemDebug/ItemDebug.Build.cs b/Source/ItemDebug/ItemDebug.Build.cs new file mode 100644 index 0000000..9a4028f --- /dev/null +++ b/Source/ItemDebug/ItemDebug.Build.cs @@ -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" + }); + } +} diff --git a/Source/ItemDebug/Private/ItemDebugModule.cpp b/Source/ItemDebug/Private/ItemDebugModule.cpp new file mode 100644 index 0000000..16f4682 --- /dev/null +++ b/Source/ItemDebug/Private/ItemDebugModule.cpp @@ -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() : 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& Args, UWorld* World) + { + if (Args.Num() < 1) { Screen(TEXT("Usage: ItemEco.Give [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& Args, UWorld* World) + { + if (Args.Num() < 1) { Screen(TEXT("Usage: ItemEco.Spawn [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&, UWorld* World) + { + UInventoryComponent* Inv = GetPlayerInventory(World); + if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; } + TArray 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&, UWorld* World) + { + UInventoryComponent* Inv = GetPlayerInventory(World); + if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; } + TArray 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&, UWorld* World) + { + UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(World); + if (!DB) { Screen(TEXT("No database."), FColor::Red); return; } + TArray 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 Mutator, const TCHAR* What) + { + UInventoryComponent* Inv = GetPlayerInventory(World); + if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; } + TArray 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&, UWorld* World) + { + UInventoryComponent* Inv = GetPlayerInventory(World); + if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; } + TArray 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&, UWorld* World) + { + MutateFirst(World, [](FItemInstanceData& It) { UItemFeatureLibrary::BreakItem(It); }, TEXT("Broke")); + } + + static void DrainBattery(const TArray& 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& 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&, UWorld* World) + { + UInventoryComponent* Inv = GetPlayerInventory(World); + if (!Inv) { Screen(TEXT("No player inventory."), FColor::Red); return; } + TArray 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& Args, UWorld* World) + { + if (Args.Num() < 1) { Screen(TEXT("Usage: ItemEco.RollLoot [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& Args, UWorld* World) + { + if (Args.Num() < 1) { Screen(TEXT("Usage: ItemEco.Craft "), 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 [Qty] to the local player inventory."), + FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::Give)); + +static FAutoConsoleCommandWithWorldAndArgs GCmdSpawn( + TEXT("ItemEco.Spawn"), TEXT("Spawn [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 [Rolls] into the player inventory."), + FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::RollLoot)); + +static FAutoConsoleCommandWithWorldAndArgs GCmdCraft( + TEXT("ItemEco.Craft"), TEXT("Craft using the player inventory."), + FConsoleCommandWithWorldAndArgsDelegate::CreateStatic(&ItemDebugCmd::Craft)); + +IMPLEMENT_MODULE(FDefaultModuleImpl, ItemDebug) diff --git a/Source/ItemDebug/Private/ItemDebugSubsystem.cpp b/Source/ItemDebug/Private/ItemDebugSubsystem.cpp new file mode 100644 index 0000000..dc9b03a --- /dev/null +++ b/Source/ItemDebug/Private/ItemDebugSubsystem.cpp @@ -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 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()) + { + 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()) + { + Target = Trace->CurrentTarget; + } + Line(FString::Printf(TEXT("Target: %s"), *GetNameSafe(Target)), FColor::Green); + + if (Target) + { + TArray 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; +} diff --git a/Source/ItemDebug/Public/ItemDebugLog.h b/Source/ItemDebug/Public/ItemDebugLog.h new file mode 100644 index 0000000..a026f5e --- /dev/null +++ b/Source/ItemDebug/Public/ItemDebugLog.h @@ -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); diff --git a/Source/ItemDebug/Public/ItemDebugSubsystem.h b/Source/ItemDebug/Public/ItemDebugSubsystem.h new file mode 100644 index 0000000..c880b34 --- /dev/null +++ b/Source/ItemDebug/Public/ItemDebugSubsystem.h @@ -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; +}; diff --git a/Source/ItemDeveloper/ItemDeveloper.Build.cs b/Source/ItemDeveloper/ItemDeveloper.Build.cs new file mode 100644 index 0000000..9556fb8 --- /dev/null +++ b/Source/ItemDeveloper/ItemDeveloper.Build.cs @@ -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" + }); + } +} diff --git a/Source/ItemDeveloper/Private/ItemDeveloperModule.cpp b/Source/ItemDeveloper/Private/ItemDeveloperModule.cpp new file mode 100644 index 0000000..50971d9 --- /dev/null +++ b/Source/ItemDeveloper/Private/ItemDeveloperModule.cpp @@ -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 Rows; + ItemsTable->GetAllRows(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 Rows; + ItemsTable->GetAllRows(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) diff --git a/Source/ItemDeveloper/Public/ItemDeveloperModule.h b/Source/ItemDeveloper/Public/ItemDeveloperModule.h new file mode 100644 index 0000000..4bdbcd3 --- /dev/null +++ b/Source/ItemDeveloper/Public/ItemDeveloperModule.h @@ -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(); +}; diff --git a/Source/ItemInteraction/ItemInteraction.Build.cs b/Source/ItemInteraction/ItemInteraction.Build.cs new file mode 100644 index 0000000..59e02fa --- /dev/null +++ b/Source/ItemInteraction/ItemInteraction.Build.cs @@ -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[] + { + }); + } +} diff --git a/Source/ItemInteraction/Private/InteractableComponent.cpp b/Source/ItemInteraction/Private/InteractableComponent.cpp new file mode 100644 index 0000000..c2b2b49 --- /dev/null +++ b/Source/ItemInteraction/Private/InteractableComponent.cpp @@ -0,0 +1,9 @@ +// Copyright ExByte Studios. Item Interaction Ecosystem. + +#include "InteractableComponent.h" + +UInteractableComponent::UInteractableComponent() +{ + PrimaryComponentTick.bCanEverTick = false; + SetIsReplicatedByDefault(true); +} diff --git a/Source/ItemInteraction/Private/InteractionAction.cpp b/Source/ItemInteraction/Private/InteractionAction.cpp new file mode 100644 index 0000000..6ae6a9c --- /dev/null +++ b/Source/ItemInteraction/Private/InteractionAction.cpp @@ -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 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 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; +} diff --git a/Source/ItemInteraction/Private/InteractionResolver.cpp b/Source/ItemInteraction/Private/InteractionResolver.cpp new file mode 100644 index 0000000..8544166 --- /dev/null +++ b/Source/ItemInteraction/Private/InteractionResolver.cpp @@ -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. tag. Empty string = always true. + */ + bool EvaluateRequirementString(const FItemInstanceData& Subject, const FString& Req) + { + FString Trimmed = Req.TrimStartAndEnd(); + if (Trimmed.IsEmpty()) + { + return true; + } + + // Find the comparison operator (order matters: check 2-char first). + const TCHAR* Ops[] = { TEXT(">="), TEXT("<="), TEXT("=="), TEXT(">"), TEXT("<") }; + FString Op, Left, Right; + for (const TCHAR* Candidate : Ops) + { + int32 Idx; + if (Trimmed.FindChar(Candidate[0], Idx)) + { + if (Trimmed.Mid(Idx, FCString::Strlen(Candidate)) == Candidate) + { + Op = Candidate; + Left = Trimmed.Left(Idx).TrimStartAndEnd(); + Right = Trimmed.Mid(Idx + FCString::Strlen(Candidate)).TrimStartAndEnd(); + break; + } + } + } + if (Op.IsEmpty()) + { + return true; // unparseable -> don't block + } + + const FGameplayTag PropTag = FGameplayTag::RequestGameplayTag(FName(*(FString(TEXT("Property.")) + Left)), false); + const float Value = Subject.RuntimeProperties.GetFloat(PropTag, 0.f); + const float Threshold = FCString::Atof(*Right); + + if (Op == TEXT(">")) { return Value > Threshold; } + if (Op == TEXT("<")) { return Value < Threshold; } + if (Op == TEXT(">=")) { return Value >= Threshold; } + if (Op == TEXT("<=")) { return Value <= Threshold; } + if (Op == TEXT("==")) { return FMath::IsNearlyEqual(Value, Threshold); } + return true; + } + + bool AnyContainerAccepts(UInventoryComponent* Inv, const FItemInstanceData& Item) + { + if (!Inv) { return false; } + TArray Containers; + Inv->GetContainers(Containers); + for (const FInventoryContainer& C : Containers) + { + if (Inv->CanAcceptItem(Item, C.ContainerId)) { return true; } + } + return false; + } +} + +bool UInteractionResolver::ResolveHeldItem(APawn* Instigator, FItemInstanceData& OutItem) +{ + if (!Instigator) { return false; } + + auto TryObject = [&OutItem](UObject* Obj) -> bool + { + if (Obj && Obj->Implements()) + { + IHeldItemInterface* Held = Cast(Obj); + if (Held && Held->HasHeldItem()) + { + return Held->GetHeldItem(OutItem); + } + } + return false; + }; + + if (TryObject(Instigator)) { return true; } + for (UActorComponent* Comp : Instigator->GetComponents()) + { + if (TryObject(Comp)) { return true; } + } + return false; +} + +FInteractionContext UInteractionResolver::BuildContext(APawn* Instigator, AActor* TargetActor) +{ + FInteractionContext Ctx; + Ctx.TargetActor = TargetActor; + Ctx.Character = Cast(Instigator); + if (Instigator) + { + Ctx.PlayerController = Cast(Instigator->GetController()); + Ctx.PlayerInventory = Instigator->FindComponentByClass(); + Ctx.bHasHeldItem = ResolveHeldItem(Instigator, Ctx.HeldItem); + } + + if (TargetActor) + { + Ctx.TargetInteractable = TargetActor->FindComponentByClass(); + if (Ctx.TargetInteractable) + { + Ctx.WorldTags = Ctx.TargetInteractable->InteractionTags; + } + if (TargetActor->Implements()) + { + if (IWorldItemProvider* Provider = Cast(TargetActor)) + { + Ctx.bHasTargetItem = Provider->GetProvidedItem(Ctx.TargetItem); + } + } + } + return Ctx; +} + +void UInteractionResolver::GatherCapabilityOptions(const FInteractionContext& Ctx, TArray& Out) +{ + if (!Ctx.bHasTargetItem) + { + return; + } + const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(Ctx.TargetActor); + const FItemDefinitionRow* Def = DB ? DB->GetItemDefinition(Ctx.TargetItem.ItemId) : nullptr; + if (!Def) + { + return; + } + + const FText Name = Def->DisplayName.IsEmpty() ? FText::FromName(Ctx.TargetItem.ItemId) : Def->DisplayName; + + if (Def->bCanInstantPickup || Def->bCanStoreInInventory) + { + const EItemInteractionMode Mode = Def->bCanInstantPickup ? EItemInteractionMode::InstantPickup : EItemInteractionMode::PickupToInventory; + const FGameplayTag Tag = Def->bCanInstantPickup ? ItemEcosystemTags::Action_Pickup_Instant : ItemEcosystemTags::Action_Pickup; + FText Text = Ctx.TargetItem.Quantity > 1 + ? FText::Format(LOCTEXT("PickUpQty", "Pick up {0} x{1}"), Name, FText::AsNumber(Ctx.TargetItem.Quantity)) + : FText::Format(LOCTEXT("PickUp", "Pick up {0}"), Name); + FInteractionOption Opt = MakeOption(Ctx, Tag, Mode, Text, 600); + if (!AnyContainerAccepts(Ctx.PlayerInventory, Ctx.TargetItem)) + { + Opt.bIsEnabled = false; + Opt.DisabledReason = LOCTEXT("InventoryFull", "Inventory Full"); + } + Out.Add(Opt); + } + + if (Def->bCanWorldCarry) + { + FInteractionOption Opt = MakeOption(Ctx, ItemEcosystemTags::Action_Carry, EItemInteractionMode::CarryAttached, + FText::Format(LOCTEXT("Carry", "Carry {0}"), Name), 500); + if (Ctx.bHasHeldItem) + { + Opt.bIsEnabled = false; + Opt.DisabledReason = LOCTEXT("HandsFull", "Hands Full"); + } + Out.Add(Opt); + } +} + +void UInteractionResolver::GatherContainerOptions(const FInteractionContext& Ctx, TArray& Out) +{ + if (!Ctx.TargetActor) + { + return; + } + // A target that owns an inventory (and isn't the player themselves) can be opened. + UInventoryComponent* TargetInv = Ctx.TargetActor->FindComponentByClass(); + if (!TargetInv || TargetInv == Ctx.PlayerInventory) + { + return; + } + + FText Label = (Ctx.TargetInteractable && !Ctx.TargetInteractable->DisplayName.IsEmpty()) + ? FText::Format(LOCTEXT("OpenNamed", "Open {0}"), Ctx.TargetInteractable->DisplayName) + : LOCTEXT("Open", "Open"); + Out.Add(MakeOption(Ctx, ItemEcosystemTags::Action_Open, EItemInteractionMode::OpenLootUI, Label, 700)); +} + +void UInteractionResolver::GatherDataDrivenOptions(const FInteractionContext& Ctx, TArray& Out) +{ + const UObject* WorldCtx = Ctx.TargetActor ? static_cast(Ctx.TargetActor) : static_cast(Ctx.Character); + const UItemDatabaseSubsystem* DB = UItemDatabaseSubsystem::Get(WorldCtx); + if (!DB) + { + return; + } + + // For a "Held+Vehicle" style context, the suffix after '+' must match one of the + // target's world tags or its interactable type (loose, case-insensitive). + auto TargetMatchesSuffix = [&Ctx](const FString& Suffix) -> bool + { + if (Suffix.IsEmpty()) { return true; } + if (Ctx.TargetInteractable && Ctx.TargetInteractable->InteractableType.IsValid() + && Ctx.TargetInteractable->InteractableType.ToString().Contains(Suffix)) + { + return true; + } + for (const FGameplayTag& Tag : Ctx.WorldTags) + { + if (Tag.ToString().Contains(Suffix)) { return true; } + } + return false; + }; + + auto AppendRows = [&](const FItemInstanceData& Item, bool bHeldContext) + { + TArray Rows; + DB->GetInteractionRows(Item.ItemId, Rows); + for (const FItemInteractionRow& Row : Rows) + { + const FString Context = Row.Context.ToString(); + const bool bIsHeldRow = Context.StartsWith(TEXT("Held")); + if (bHeldContext != bIsHeldRow) + { + continue; + } + if (bIsHeldRow) + { + FString Suffix; + Context.Split(TEXT("+"), nullptr, &Suffix); + if (!TargetMatchesSuffix(Suffix)) { continue; } + } + FInteractionOption Opt = MakeOption(Ctx, Row.ActionTag, Row.InteractionMode, + FText::FromName(Row.ActionTag.GetTagName()), Row.Priority, + Row.bRequiresHold, Row.HoldDuration, Row.ActionDuration); + Opt.Request.ItemInstanceId = Item.InstanceId; + Opt.Request.Quantity = Item.Quantity; + + // Data-driven requirement gate (section 18). + if (!Row.Requirement.IsEmpty() && !EvaluateRequirementString(Item, Row.Requirement)) + { + Opt.bIsEnabled = false; + Opt.DisabledReason = FText::FromString(Row.Requirement); + } + Out.Add(Opt); + } + }; + + if (Ctx.bHasTargetItem) { AppendRows(Ctx.TargetItem, /*bHeldContext*/ false); } + if (Ctx.bHasHeldItem) { AppendRows(Ctx.HeldItem, /*bHeldContext*/ true); } +} + +void UInteractionResolver::GatherActionClassOptions(const FInteractionContext& Ctx, TArray& Out) +{ + if (!Ctx.TargetInteractable) + { + return; + } + for (const UInteractionAction* Action : Ctx.TargetInteractable->AvailableActions) + { + if (Action) + { + Out.Add(Action->BuildOption(Ctx)); + } + } +} + +void UInteractionResolver::GetAvailableInteractions(APawn* Instigator, AActor* TargetActor, TArray& OutOptions) +{ + if (!TargetActor) + { + return; + } + const FInteractionContext Ctx = BuildContext(Instigator, TargetActor); + + GatherActionClassOptions(Ctx, OutOptions); + GatherCapabilityOptions(Ctx, OutOptions); + GatherContainerOptions(Ctx, OutOptions); + GatherDataDrivenOptions(Ctx, OutOptions); + + OutOptions.StableSort([](const FInteractionOption& A, const FInteractionOption& B) + { + return A.Priority > B.Priority; + }); +} + +bool UInteractionResolver::GetPrimaryInteraction(APawn* Instigator, AActor* TargetActor, FInteractionOption& OutOption) +{ + TArray Options; + GetAvailableInteractions(Instigator, TargetActor, Options); + for (const FInteractionOption& Opt : Options) + { + if (Opt.bIsEnabled) + { + OutOption = Opt; + return true; + } + } + if (Options.Num() > 0) + { + OutOption = Options[0]; // disabled, but lets UI show the reason + return true; + } + return false; +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/ItemInteraction/Private/InteractionTraceComponent.cpp b/Source/ItemInteraction/Private/InteractionTraceComponent.cpp new file mode 100644 index 0000000..54327ac --- /dev/null +++ b/Source/ItemInteraction/Private/InteractionTraceComponent.cpp @@ -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(GetOwner()); + const APlayerController* PC = Pawn ? Cast(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() : 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); +} diff --git a/Source/ItemInteraction/Private/ItemInteractionActions.cpp b/Source/ItemInteraction/Private/ItemInteractionActions.cpp new file mode 100644 index 0000000..3b67640 --- /dev/null +++ b/Source/ItemInteraction/Private/ItemInteractionActions.cpp @@ -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()) + { + return; + } + IWorldItemProvider* Provider = Cast(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"); +} diff --git a/Source/ItemInteraction/Private/ItemInteractionComponent.cpp b/Source/ItemInteraction/Private/ItemInteractionComponent.cpp new file mode 100644 index 0000000..c9b4ab7 --- /dev/null +++ b/Source/ItemInteraction/Private/ItemInteractionComponent.cpp @@ -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& 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(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 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()) + { + return false; + } + IWorldItemProvider* Provider = Cast(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() : 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 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() : 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() : nullptr) + { + Inv->SplitStack(InstanceId, Amount); + } +} + +void UItemInteractionComponent::Server_MergeStack_Implementation(FGuid SourceInstanceId, FGuid TargetInstanceId) +{ + APawn* Pawn = GetPawn(); + if (UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass() : 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() : 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); +} diff --git a/Source/ItemInteraction/Private/ItemInteractionModule.cpp b/Source/ItemInteraction/Private/ItemInteractionModule.cpp new file mode 100644 index 0000000..81406b6 --- /dev/null +++ b/Source/ItemInteraction/Private/ItemInteractionModule.cpp @@ -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) diff --git a/Source/ItemInteraction/Public/InteractableComponent.h b/Source/ItemInteraction/Public/InteractableComponent.h new file mode 100644 index 0000000..cc73006 --- /dev/null +++ b/Source/ItemInteraction/Public/InteractableComponent.h @@ -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> AvailableActions; + + /** Server-side soft claim so two players don't interact with the same object at once. */ + UPROPERTY(BlueprintReadOnly, Category = "Interaction") + bool bIsBusy = false; +}; diff --git a/Source/ItemInteraction/Public/InteractionAction.h b/Source/ItemInteraction/Public/InteractionAction.h new file mode 100644 index 0000000..001c603 --- /dev/null +++ b/Source/ItemInteraction/Public/InteractionAction.h @@ -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 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; +}; diff --git a/Source/ItemInteraction/Public/InteractionResolver.h b/Source/ItemInteraction/Public/InteractionResolver.h new file mode 100644 index 0000000..65145f9 --- /dev/null +++ b/Source/ItemInteraction/Public/InteractionResolver.h @@ -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& 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& Out); + static void GatherContainerOptions(const FInteractionContext& Ctx, TArray& Out); + static void GatherDataDrivenOptions(const FInteractionContext& Ctx, TArray& Out); + static void GatherActionClassOptions(const FInteractionContext& Ctx, TArray& Out); +}; diff --git a/Source/ItemInteraction/Public/InteractionTraceComponent.h b/Source/ItemInteraction/Public/InteractionTraceComponent.h new file mode 100644 index 0000000..a05f80f --- /dev/null +++ b/Source/ItemInteraction/Public/InteractionTraceComponent.h @@ -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 TraceChannel = ECC_Visibility; + + UPROPERTY(BlueprintReadOnly, Category = "Interaction") + TObjectPtr CurrentTarget = nullptr; + + UPROPERTY(BlueprintReadOnly, Category = "Interaction") + TObjectPtr 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); +}; diff --git a/Source/ItemInteraction/Public/InteractionTypes.h b/Source/ItemInteraction/Public/InteractionTypes.h new file mode 100644 index 0000000..a361f5f --- /dev/null +++ b/Source/ItemInteraction/Public/InteractionTypes.h @@ -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 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 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 PlayerController = nullptr; + + UPROPERTY(BlueprintReadOnly, Category = "Interaction") + TObjectPtr Character = nullptr; + + UPROPERTY(BlueprintReadOnly, Category = "Interaction") + TObjectPtr TargetActor = nullptr; + + UPROPERTY(BlueprintReadOnly, Category = "Interaction") + TObjectPtr TargetInteractable = nullptr; + + UPROPERTY(BlueprintReadOnly, Category = "Interaction") + TObjectPtr 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; +}; diff --git a/Source/ItemInteraction/Public/ItemInteractionActions.h b/Source/ItemInteraction/Public/ItemInteractionActions.h new file mode 100644 index 0000000..7da41f0 --- /dev/null +++ b/Source/ItemInteraction/Public/ItemInteractionActions.h @@ -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(); +}; diff --git a/Source/ItemInteraction/Public/ItemInteractionComponent.h b/Source/ItemInteraction/Public/ItemInteractionComponent.h new file mode 100644 index 0000000..dd9e86b --- /dev/null +++ b/Source/ItemInteraction/Public/ItemInteractionComponent.h @@ -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& 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; +}; diff --git a/Source/ItemInteraction/Public/ItemInteractionInterfaces.h b/Source/ItemInteraction/Public/ItemInteractionInterfaces.h new file mode 100644 index 0000000..ba5d0a8 --- /dev/null +++ b/Source/ItemInteraction/Public/ItemInteractionInterfaces.h @@ -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; +}; diff --git a/Source/ItemInteraction/Public/ItemInteractionLog.h b/Source/ItemInteraction/Public/ItemInteractionLog.h new file mode 100644 index 0000000..abbfbb3 --- /dev/null +++ b/Source/ItemInteraction/Public/ItemInteractionLog.h @@ -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); diff --git a/Source/ItemInventory/ItemInventory.Build.cs b/Source/ItemInventory/ItemInventory.Build.cs new file mode 100644 index 0000000..7c387ec --- /dev/null +++ b/Source/ItemInventory/ItemInventory.Build.cs @@ -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[] + { + }); + } +} diff --git a/Source/ItemInventory/Private/InventoryComponent.cpp b/Source/ItemInventory/Private/InventoryComponent.cpp new file mode 100644 index 0000000..1aba8b8 --- /dev/null +++ b/Source/ItemInventory/Private/InventoryComponent.cpp @@ -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& 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 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& OutItems) const +{ + for (const FRepInventoryEntry& E : ReplicatedItems.Entries) + { + if (E.ContainerId == ContainerId) + { + OutItems.Add(E.Item); + } + } +} + +void UInventoryComponent::GetAllItems(TArray& 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 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& 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); +} diff --git a/Source/ItemInventory/Private/ItemCraftingLibrary.cpp b/Source/ItemInventory/Private/ItemCraftingLibrary.cpp new file mode 100644 index 0000000..c4b9b50 --- /dev/null +++ b/Source/ItemInventory/Private/ItemCraftingLibrary.cpp @@ -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 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 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; +} diff --git a/Source/ItemInventory/Private/ItemFeatureLibrary.cpp b/Source/ItemInventory/Private/ItemFeatureLibrary.cpp new file mode 100644 index 0000000..8308ed4 --- /dev/null +++ b/Source/ItemInventory/Private/ItemFeatureLibrary.cpp @@ -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 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 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 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); } +} diff --git a/Source/ItemInventory/Private/ItemInventoryModule.cpp b/Source/ItemInventory/Private/ItemInventoryModule.cpp new file mode 100644 index 0000000..ba4a80e --- /dev/null +++ b/Source/ItemInventory/Private/ItemInventoryModule.cpp @@ -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) diff --git a/Source/ItemInventory/Private/ItemSaveLibrary.cpp b/Source/ItemInventory/Private/ItemSaveLibrary.cpp new file mode 100644 index 0000000..f2c8795 --- /dev/null +++ b/Source/ItemInventory/Private/ItemSaveLibrary.cpp @@ -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(UGameplayStatics::LoadGameFromSlot(SlotName, 0)); + if (!Save) + { + Save = Cast(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(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; +} diff --git a/Source/ItemInventory/Private/ItemTransaction.cpp b/Source/ItemInventory/Private/ItemTransaction.cpp new file mode 100644 index 0000000..66651bf --- /dev/null +++ b/Source/ItemInventory/Private/ItemTransaction.cpp @@ -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 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; + } +} diff --git a/Source/ItemInventory/Private/ReplicatedInventoryList.cpp b/Source/ItemInventory/Private/ReplicatedInventoryList.cpp new file mode 100644 index 0000000..4eec8b5 --- /dev/null +++ b/Source/ItemInventory/Private/ReplicatedInventoryList.cpp @@ -0,0 +1,103 @@ +// Copyright ExByte Studios. Item Interaction Ecosystem. + +#include "ReplicatedInventoryList.h" +#include "InventoryComponent.h" + +void FReplicatedInventoryList::PostReplicatedAdd(const TArrayView& 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& /*ChangedIndices*/, int32 /*FinalSize*/) +{ + if (OwnerComponent) + { + OwnerComponent->NotifyInventoryChanged(); + } +} + +void FReplicatedInventoryList::PreReplicatedRemove(const TArrayView& 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; + }); +} diff --git a/Source/ItemInventory/Public/InventoryComponent.h b/Source/ItemInventory/Public/InventoryComponent.h new file mode 100644 index 0000000..92126c7 --- /dev/null +++ b/Source/ItemInventory/Public/InventoryComponent.h @@ -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& 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& 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& OutItems) const; + + UFUNCTION(BlueprintPure, Category = "Inventory") + void GetAllItems(TArray& 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& 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 Containers; + + /** Default containers spawned in InitializeComponent (designer-configurable). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory|Setup") + TArray 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 ServerOnlyProps; +}; diff --git a/Source/ItemInventory/Public/InventoryContainer.h b/Source/ItemInventory/Public/InventoryContainer.h new file mode 100644 index 0000000..108bc86 --- /dev/null +++ b/Source/ItemInventory/Public/InventoryContainer.h @@ -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 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; } +}; diff --git a/Source/ItemInventory/Public/ItemCraftingLibrary.h b/Source/ItemInventory/Public/ItemCraftingLibrary.h new file mode 100644 index 0000000..8ed8cc6 --- /dev/null +++ b/Source/ItemInventory/Public/ItemCraftingLibrary.h @@ -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); +}; diff --git a/Source/ItemInventory/Public/ItemFeatureLibrary.h b/Source/ItemInventory/Public/ItemFeatureLibrary.h new file mode 100644 index 0000000..6439d9b --- /dev/null +++ b/Source/ItemInventory/Public/ItemFeatureLibrary.h @@ -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); +}; diff --git a/Source/ItemInventory/Public/ItemInventoryLog.h b/Source/ItemInventory/Public/ItemInventoryLog.h new file mode 100644 index 0000000..9431677 --- /dev/null +++ b/Source/ItemInventory/Public/ItemInventoryLog.h @@ -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); diff --git a/Source/ItemInventory/Public/ItemSaveGame.h b/Source/ItemInventory/Public/ItemSaveGame.h new file mode 100644 index 0000000..a6e6ac8 --- /dev/null +++ b/Source/ItemInventory/Public/ItemSaveGame.h @@ -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 Items; + + UPROPERTY(SaveGame) + TArray 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 Inventories; +}; diff --git a/Source/ItemInventory/Public/ItemSaveLibrary.h b/Source/ItemInventory/Public/ItemSaveLibrary.h new file mode 100644 index 0000000..03ac327 --- /dev/null +++ b/Source/ItemInventory/Public/ItemSaveLibrary.h @@ -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); +}; diff --git a/Source/ItemInventory/Public/ItemTransaction.h b/Source/ItemInventory/Public/ItemTransaction.h new file mode 100644 index 0000000..04c2f42 --- /dev/null +++ b/Source/ItemInventory/Public/ItemTransaction.h @@ -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 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 Instigator; + + UPROPERTY() + TArray 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(); +}; diff --git a/Source/ItemInventory/Public/ReplicatedInventoryList.h b/Source/ItemInventory/Public/ReplicatedInventoryList.h new file mode 100644 index 0000000..bee7d3b --- /dev/null +++ b/Source/ItemInventory/Public/ReplicatedInventoryList.h @@ -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 Entries; + + /** Not replicated - set by the owning component so callbacks can broadcast. */ + UPROPERTY(NotReplicated) + TObjectPtr OwnerComponent = nullptr; + + bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms) + { + return FFastArraySerializer::FastArrayDeltaSerialize(Entries, DeltaParms, *this); + } + + //~ FFastArraySerializer client callbacks + void PostReplicatedAdd(const TArrayView& AddedIndices, int32 FinalSize); + void PostReplicatedChange(const TArrayView& ChangedIndices, int32 FinalSize); + void PreReplicatedRemove(const TArrayView& 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 : public TStructOpsTypeTraitsBase2 +{ + enum + { + WithNetDeltaSerializer = true + }; +}; diff --git a/Source/ItemPlacement/ItemPlacement.Build.cs b/Source/ItemPlacement/ItemPlacement.Build.cs new file mode 100644 index 0000000..06f0048 --- /dev/null +++ b/Source/ItemPlacement/ItemPlacement.Build.cs @@ -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" + }); + } +} diff --git a/Source/ItemPlacement/Private/ItemPlacementModule.cpp b/Source/ItemPlacement/Private/ItemPlacementModule.cpp new file mode 100644 index 0000000..d929589 --- /dev/null +++ b/Source/ItemPlacement/Private/ItemPlacementModule.cpp @@ -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) diff --git a/Source/ItemPlacement/Private/PlacementInteractionActions.cpp b/Source/ItemPlacement/Private/PlacementInteractionActions.cpp new file mode 100644 index 0000000..ec93bd5 --- /dev/null +++ b/Source/ItemPlacement/Private/PlacementInteractionActions.cpp @@ -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() : 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(); } +} diff --git a/Source/ItemPlacement/Private/PlacementPreviewComponent.cpp b/Source/ItemPlacement/Private/PlacementPreviewComponent.cpp new file mode 100644 index 0000000..ca9c726 --- /dev/null +++ b/Source/ItemPlacement/Private/PlacementPreviewComponent.cpp @@ -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(GetOwner()); + const APlayerController* PC = Pawn ? Cast(Pawn->GetController()) : nullptr; + if (!PC || !PC->IsLocalController()) + { + return false; + } + PC->GetPlayerViewPoint(OutLoc, OutRot); + return true; +} + +void UPlacementPreviewComponent::StartPlacement(FGuid InstanceId) +{ + APawn* Pawn = Cast(GetOwner()); + UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass() : 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(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(GetOwner()); + UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass() : 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); +} diff --git a/Source/ItemPlacement/Public/ItemPlacementLog.h b/Source/ItemPlacement/Public/ItemPlacementLog.h new file mode 100644 index 0000000..87e3de0 --- /dev/null +++ b/Source/ItemPlacement/Public/ItemPlacementLog.h @@ -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); diff --git a/Source/ItemPlacement/Public/PlacementInteractionActions.h b/Source/ItemPlacement/Public/PlacementInteractionActions.h new file mode 100644 index 0000000..c27e110 --- /dev/null +++ b/Source/ItemPlacement/Public/PlacementInteractionActions.h @@ -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; +}; diff --git a/Source/ItemPlacement/Public/PlacementPreviewComponent.h b/Source/ItemPlacement/Public/PlacementPreviewComponent.h new file mode 100644 index 0000000..62eae2b --- /dev/null +++ b/Source/ItemPlacement/Public/PlacementPreviewComponent.h @@ -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 Ghost = nullptr; + + float PreviewYaw = 0.f; + + bool ComputePreviewTransform(FTransform& OutTransform) const; + void DestroyGhost(); + void SetValidity(bool bNewValid); + bool GetViewPoint(FVector& OutLoc, FRotator& OutRot) const; +}; diff --git a/Source/ItemWorld/ItemWorld.Build.cs b/Source/ItemWorld/ItemWorld.Build.cs new file mode 100644 index 0000000..bd6560f --- /dev/null +++ b/Source/ItemWorld/ItemWorld.Build.cs @@ -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[] + { + }); + } +} diff --git a/Source/ItemWorld/Private/EquipmentComponent.cpp b/Source/ItemWorld/Private/EquipmentComponent.cpp new file mode 100644 index 0000000..6aaa13f --- /dev/null +++ b/Source/ItemWorld/Private/EquipmentComponent.cpp @@ -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& 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(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(); + 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(GetOwner()); + AActor* Spawned = GetWorld()->SpawnActor(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(); +} diff --git a/Source/ItemWorld/Private/FuelTankComponent.cpp b/Source/ItemWorld/Private/FuelTankComponent.cpp new file mode 100644 index 0000000..130b218 --- /dev/null +++ b/Source/ItemWorld/Private/FuelTankComponent.cpp @@ -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& 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); +} diff --git a/Source/ItemWorld/Private/HeldItemComponent.cpp b/Source/ItemWorld/Private/HeldItemComponent.cpp new file mode 100644 index 0000000..12a780a --- /dev/null +++ b/Source/ItemWorld/Private/HeldItemComponent.cpp @@ -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(WorldItem); + FItemInstanceData Item; + if (!Provider || !Provider->IsItemAvailable() || !Provider->GetProvidedItem(Item) || !CanHoldItem(Item)) + { + return false; + } + + UPrimitiveComponent* Prim = nullptr; + if (APickupItemActor* Pickup = Cast(WorldItem)) + { + Prim = Pickup->GetMeshComponent(); + } + if (!Prim) + { + Prim = Cast(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(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(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& 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(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(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 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(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 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(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(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; +} diff --git a/Source/ItemWorld/Private/ItemWorldModule.cpp b/Source/ItemWorld/Private/ItemWorldModule.cpp new file mode 100644 index 0000000..ed3414c --- /dev/null +++ b/Source/ItemWorld/Private/ItemWorldModule.cpp @@ -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) diff --git a/Source/ItemWorld/Private/PickupItemActor.cpp b/Source/ItemWorld/Private/PickupItemActor.cpp new file mode 100644 index 0000000..249312e --- /dev/null +++ b/Source/ItemWorld/Private/PickupItemActor.cpp @@ -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(TEXT("Root")); + SetRootComponent(Root); + + Mesh = CreateDefaultSubobject(TEXT("Mesh")); + Mesh->SetupAttachment(Root); + Mesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); + Mesh->SetCollisionResponseToAllChannels(ECR_Block); + Mesh->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap); + + Interactable = CreateDefaultSubobject(TEXT("Interactable")); +} + +void APickupItemActor::GetLifetimeReplicatedProps(TArray& 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 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(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; + } +} diff --git a/Source/ItemWorld/Private/StorageContainerActor.cpp b/Source/ItemWorld/Private/StorageContainerActor.cpp new file mode 100644 index 0000000..2402622 --- /dev/null +++ b/Source/ItemWorld/Private/StorageContainerActor.cpp @@ -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(TEXT("Mesh")); + SetRootComponent(Mesh); + + Interactable = CreateDefaultSubobject(TEXT("Interactable")); + + Storage = CreateDefaultSubobject(TEXT("Storage")); + // Shared container: visible to whoever opens it, not just an owning client. + Storage->bOwnerOnlyReplication = false; +} diff --git a/Source/ItemWorld/Private/WorldInteractionActions.cpp b/Source/ItemWorld/Private/WorldInteractionActions.cpp new file mode 100644 index 0000000..d6b5aa6 --- /dev/null +++ b/Source/ItemWorld/Private/WorldInteractionActions.cpp @@ -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()) + { + 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); +} diff --git a/Source/ItemWorld/Private/WorldInteractionComponent.cpp b/Source/ItemWorld/Private/WorldInteractionComponent.cpp new file mode 100644 index 0000000..85922ef --- /dev/null +++ b/Source/ItemWorld/Private/WorldInteractionComponent.cpp @@ -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(); + 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(); + return Held && Held->ServerDropHeldItem(); +} + +bool UWorldInteractionComponent::ExecuteInstall(const FInteractionContext& Context) +{ + UHeldItemComponent* Held = GetOwner()->FindComponentByClass(); + 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 Slots; + Context.TargetActor->GetComponents(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(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(); + 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(); + 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 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(Context.TargetActor); + if (!UseTarget) + { + for (UActorComponent* Comp : Context.TargetActor->GetComponents()) + { + if (IItemUseTarget* AsTarget = Cast(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 Slots; + Context.TargetActor->GetComponents(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(GetOwner()); + UInventoryComponent* Inv = Pawn ? Pawn->FindComponentByClass() : 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; +} diff --git a/Source/ItemWorld/Private/WorldItemSlotComponent.cpp b/Source/ItemWorld/Private/WorldItemSlotComponent.cpp new file mode 100644 index 0000000..98d5aa0 --- /dev/null +++ b/Source/ItemWorld/Private/WorldItemSlotComponent.cpp @@ -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& 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(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(); +} diff --git a/Source/ItemWorld/Public/EquipmentComponent.h b/Source/ItemWorld/Public/EquipmentComponent.h new file mode 100644 index 0000000..f826ddd --- /dev/null +++ b/Source/ItemWorld/Public/EquipmentComponent.h @@ -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 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& OutLifetimeProps) const override; + virtual void OnUnregister() override; + + /** Socket per slot tag; falls back to DefaultEquipSocket. */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Equipment") + TMap 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& OutEntries) const { OutEntries = Equipped; } + +protected: + UPROPERTY(ReplicatedUsing = OnRep_Equipped) + TArray Equipped; + + UFUNCTION() + void OnRep_Equipped(); + + FName ResolveSocket(const FGameplayTag& SlotTag) const; + USceneComponent* GetAttachTarget() const; + bool HasAuthority() const; +}; diff --git a/Source/ItemWorld/Public/FuelTankComponent.h b/Source/ItemWorld/Public/FuelTankComponent.h new file mode 100644 index 0000000..4df0031 --- /dev/null +++ b/Source/ItemWorld/Public/FuelTankComponent.h @@ -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& 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; +}; diff --git a/Source/ItemWorld/Public/HeldItemComponent.h b/Source/ItemWorld/Public/HeldItemComponent.h new file mode 100644 index 0000000..b1e895f --- /dev/null +++ b/Source/ItemWorld/Public/HeldItemComponent.h @@ -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 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& 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 PhysicsHandle = nullptr; + + void UpdatePhysicsCarryTarget(); +}; diff --git a/Source/ItemWorld/Public/ItemWorldLog.h b/Source/ItemWorld/Public/ItemWorldLog.h new file mode 100644 index 0000000..d3bc821 --- /dev/null +++ b/Source/ItemWorld/Public/ItemWorldLog.h @@ -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); diff --git a/Source/ItemWorld/Public/PickupItemActor.h b/Source/ItemWorld/Public/PickupItemActor.h new file mode 100644 index 0000000..86e5462 --- /dev/null +++ b/Source/ItemWorld/Public/PickupItemActor.h @@ -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& 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 Root; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|World") + TObjectPtr Mesh; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|World") + TObjectPtr 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 CurrentHolder = nullptr; + + UFUNCTION() + void OnRep_ItemData(); + + /** Resolves the definition and applies WorldMesh + interactable display name. */ + void RefreshVisuals(); +}; diff --git a/Source/ItemWorld/Public/StorageContainerActor.h b/Source/ItemWorld/Public/StorageContainerActor.h new file mode 100644 index 0000000..9393aaf --- /dev/null +++ b/Source/ItemWorld/Public/StorageContainerActor.h @@ -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 Mesh; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage") + TObjectPtr Interactable; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Storage") + TObjectPtr Storage; +}; diff --git a/Source/ItemWorld/Public/WorldInteractionActions.h b/Source/ItemWorld/Public/WorldInteractionActions.h new file mode 100644 index 0000000..37bd71b --- /dev/null +++ b/Source/ItemWorld/Public/WorldInteractionActions.h @@ -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; +}; diff --git a/Source/ItemWorld/Public/WorldInteractionComponent.h b/Source/ItemWorld/Public/WorldInteractionComponent.h new file mode 100644 index 0000000..b10067f --- /dev/null +++ b/Source/ItemWorld/Public/WorldInteractionComponent.h @@ -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); +}; diff --git a/Source/ItemWorld/Public/WorldItemSlotComponent.h b/Source/ItemWorld/Public/WorldItemSlotComponent.h new file mode 100644 index 0000000..4d4ca0f --- /dev/null +++ b/Source/ItemWorld/Public/WorldItemSlotComponent.h @@ -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& 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 InstalledVisual = nullptr; + + UFUNCTION() + void OnRep_InstalledItem(); + + USceneComponent* GetAttachTarget() const; + bool HasAuthority() const; +};