using UnityEngine; using UnityEngine.Events; using FishNet.Connection; using FishNet.Object; using Ashwild.Network; using Ashwild.Player; namespace Ashwild.Inventory { /// /// The local player's inventory. It is client-authoritative: each player manages its own /// slots locally (crafting, use, hotbar all stay local). The network layer is only used to /// (a) receive items the server granted from an authoritative pickup, and (b) spawn dropped /// world objects on the server. Only the owning client registers the Instance singleton. /// [DisallowMultipleComponent] public class PlayerInventory : NetworkBehaviour { #region Singleton /// /// The local player's inventory (set only on the owning client). /// public static PlayerInventory Instance { get; private set; } #endregion #region Serialized Fields [Header("Inventory")] [SerializeField] private int inventorySize = 30; [SerializeField] private int hotbarSize = 10; [Header("Drop")] [SerializeField] private Transform dropOrigin; [SerializeField] private float dropForwardDistance = 1.5f; [Header("Events (consumed by UI)")] public UnityEvent onSlotChanged; public UnityEvent onSelectedSlotChanged; public UnityEvent onItemAdded; #endregion #region State private InventorySlot[] slots; private int selectedHotbarIndex; public int InventorySize => inventorySize; public int HotbarSize => hotbarSize; public int SelectedHotbarIndex => selectedHotbarIndex; #endregion #region Unity Lifecycle /// /// Builds the empty slot array (local data on every copy). /// private void Awake() { slots = new InventorySlot[inventorySize]; for (int i = 0; i < inventorySize; i++) slots[i] = new InventorySlot(); } private void OnEnable() { PlayerEvents.HotbarSlotPressed += OnHotbarSlotPressed; PlayerEvents.HotbarScroll += HandleHotbarScroll; PlayerEvents.DropPressed += OnDropPressed; PlayerEvents.ItemPickedUp += OnItemPickedUp; } private void OnDisable() { PlayerEvents.HotbarSlotPressed -= OnHotbarSlotPressed; PlayerEvents.HotbarScroll -= HandleHotbarScroll; PlayerEvents.DropPressed -= OnDropPressed; PlayerEvents.ItemPickedUp -= OnItemPickedUp; } #endregion #region Network Lifecycle /// /// Registers the singleton on the owning client only (before LocalPlayerSpawned fires). /// public override void OnStartNetwork() { base.OnStartNetwork(); if (base.Owner.IsLocalClient) Instance = this; } /// /// Clears the singleton when this player leaves the network. /// public override void OnStopNetwork() { base.OnStopNetwork(); if (Instance == this) Instance = null; } #endregion #region Bus Handlers private void OnHotbarSlotPressed(int index) => SelectHotbarSlot(index); private void HandleHotbarScroll(float dir) { if (dir > 0f) SelectHotbarSlot((selectedHotbarIndex - 1 + hotbarSize) % hotbarSize); else if (dir < 0f) SelectHotbarSlot((selectedHotbarIndex + 1) % hotbarSize); } private void OnDropPressed() { if (PlayerEvents.IsInventoryOpen) return; if (PlayerStats.Instance != null && PlayerStats.Instance.IsDead) return; DropItem(selectedHotbarIndex, 1); } private void OnItemPickedUp(ItemData item, int qty) => AddItem(item, qty); #endregion #region Network Grant / Drop /// /// Server-side: sends a granted item to this inventory's owning client, where it is added. /// Called after the server authorises a pickup. /// public void GrantItemFromServer(ItemData item, int quantity) { if (item == null) return; ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0; if (id == 0) { Debug.LogError($"[PlayerInventory] '{item.ItemName}' is not in the ItemDatabase — cannot grant. Run Rebuild Item Database.", this); return; } TargetGrantItem(Owner, id, quantity); } /// /// Runs on the owning client: resolves the granted item and adds it locally. /// [TargetRpc] private void TargetGrantItem(NetworkConnection conn, ushort itemId, int quantity) { ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(itemId) : null; if (item == null) { Debug.LogError($"[PlayerInventory] Granted unknown item id {itemId}.", this); return; } AddItem(item, quantity); } #endregion #region Storage API public InventorySlot GetSlot(int index) { if (index < 0 || index >= inventorySize) return null; return slots[index]; } public InventorySlot GetSelectedSlot() => slots[selectedHotbarIndex]; public bool AddItem(ItemData item, int quantity = 1) { if (item == null) { Debug.LogError("[PlayerInventory] AddItem called with no ItemData — pickup ignored.", this); return false; } int remaining = quantity; for (int i = 0; i < inventorySize && remaining > 0; i++) { if (!slots[i].IsEmpty && slots[i].ItemData == item && slots[i].CanAccept(item)) { remaining = slots[i].AddQuantity(remaining); NotifySlotChanged(i); } } for (int i = 0; i < inventorySize && remaining > 0; i++) { if (slots[i].IsEmpty) { int toPlace = (item.IsStackable && remaining > item.MaxStackSize) ? item.MaxStackSize : remaining; slots[i].Set(item, toPlace); remaining -= toPlace; NotifySlotChanged(i); } } int added = quantity - remaining; if (added > 0) { onItemAdded?.Invoke(item, added); PlayerEvents.RaiseItemAdded(item, added); } return remaining <= 0; } /// /// Returns whether the given quantity of an item would fit without mutating anything. /// public bool CanFit(ItemData item, int quantity = 1) { if (item == null) return false; int remaining = quantity; for (int i = 0; i < inventorySize && remaining > 0; i++) { if (!slots[i].IsEmpty && slots[i].ItemData == item && slots[i].CanAccept(item)) remaining -= Mathf.Min(remaining, item.MaxStackSize - slots[i].Quantity); } for (int i = 0; i < inventorySize && remaining > 0; i++) { if (slots[i].IsEmpty) remaining -= Mathf.Min(remaining, item.IsStackable ? item.MaxStackSize : 1); } return remaining <= 0; } public void RemoveItem(int index, int quantity = 1) { if (index < 0 || index >= inventorySize) return; slots[index].RemoveQuantity(quantity); NotifySlotChanged(index); } public void SwapSlots(int indexA, int indexB) { if (indexA < 0 || indexA >= inventorySize) return; if (indexB < 0 || indexB >= inventorySize) return; ItemData tempData = slots[indexA].ItemData; int tempQty = slots[indexA].Quantity; if (slots[indexB].IsEmpty) slots[indexA].Clear(); else slots[indexA].Set(slots[indexB].ItemData, slots[indexB].Quantity); if (tempData == null) slots[indexB].Clear(); else slots[indexB].Set(tempData, tempQty); NotifySlotChanged(indexA); NotifySlotChanged(indexB); } public void UseItem(int index) { if (index < 0 || index >= inventorySize) return; if (slots[index].IsEmpty) return; ItemData item = slots[index].ItemData; if (item.ItemType != ItemType.Consumable) return; if (PlayerStats.Instance != null) { if (item.HealthRestore > 0f) PlayerStats.Instance.Heal(item.HealthRestore); if (item.HungerRestore > 0f) PlayerStats.Instance.Feed(item.HungerRestore); if (item.ThirstRestore > 0f) PlayerStats.Instance.Drink(item.ThirstRestore); } PlayerEvents.RaiseItemConsumed(item); RemoveItem(index, 1); } public void DropItem(int index, int quantity = 1) { if (index < 0 || index >= inventorySize) return; if (slots[index].IsEmpty) return; ItemData item = slots[index].ItemData; Transform origin = dropOrigin != null ? dropOrigin : transform; if (item.WorldPrefab != null && origin != null && PickableRegistry.Instance != null) { Vector3 dropPos = origin.position + origin.forward * dropForwardDistance; ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0; if (id != 0) PickableRegistry.Instance.RequestDropServerRpc(id, quantity, dropPos, origin.rotation); else Debug.LogWarning($"[PlayerInventory] '{item.ItemName}' is not in the ItemDatabase — drop not spawned. Run Rebuild Item Database.", this); } RemoveItem(index, quantity); PlayerEvents.RaiseItemDropped(item, quantity); } public void SelectHotbarSlot(int index) { if (index < 0 || index >= hotbarSize) return; selectedHotbarIndex = index; onSelectedSlotChanged?.Invoke(index); PlayerEvents.RaiseSelectedHotbarSlotChanged(index); } public bool HasItem(ItemData item, int quantity = 1) => CountItem(item) >= quantity; public int CountItem(ItemData item) { int count = 0; for (int i = 0; i < inventorySize; i++) if (!slots[i].IsEmpty && slots[i].ItemData == item) count += slots[i].Quantity; return count; } public bool RemoveItemByData(ItemData item, int quantity) { if (!HasItem(item, quantity)) return false; int remaining = quantity; for (int i = 0; i < inventorySize && remaining > 0; i++) { if (!slots[i].IsEmpty && slots[i].ItemData == item) { int toRemove = Mathf.Min(remaining, slots[i].Quantity); slots[i].RemoveQuantity(toRemove); remaining -= toRemove; NotifySlotChanged(i); } } return true; } #endregion #region Helpers private void NotifySlotChanged(int index) { onSlotChanged?.Invoke(index); PlayerEvents.RaiseInventorySlotChanged(index); } #endregion } }