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 (PlayerEvents.IsPlacingBuild) return; if (dir > 0f) SelectHotbarSlot((selectedHotbarIndex - 1 + hotbarSize) % hotbarSize); else if (dir < 0f) SelectHotbarSlot((selectedHotbarIndex + 1) % hotbarSize); } private void OnDropPressed() { if (PlayerEvents.IsInventoryOpen || PlayerEvents.IsChestOpen) 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, a cooking result or a chest withdrawal. /// restores a specific remaining-uses value, used when a partially used /// instance is picked back up off the ground (negative = grant at full uses). /// is the slot the player actually aimed at (a chest item /// dragged onto a precise inventory cell); it keeps the grant from auto-filling the first free /// slot — which is the hotbar — when the player picked a destination. Negative = auto-place. /// marks a container-to-container move (chest) so the HUD does not /// announce it as a gain. /// public void GrantItemFromServer(ItemData item, int quantity = 1, int uses = -1, int preferredIndex = -1, bool isTransfer = false) { 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, uses, preferredIndex, isTransfer); } /// /// Runs on the owning client: resolves the granted item and adds it locally, restoring its /// remaining uses and honouring the slot the player aimed at when one was requested. /// [TargetRpc] private void TargetGrantItem(NetworkConnection conn, ushort itemId, int quantity, int uses, int preferredIndex, bool isTransfer) { 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, uses, preferredIndex, isTransfer); } #endregion #region Storage API public InventorySlot GetSlot(int index) { if (index < 0 || index >= inventorySize) return null; return slots[index]; } public InventorySlot GetSelectedSlot() => slots[selectedHotbarIndex]; /// /// Reads a slot as a container-agnostic snapshot, including its remaining uses. This is how the /// shared rules see an inventory slot. /// public SlotContent ReadSlot(int index) { InventorySlot slot = GetSlot(index); if (slot == null || slot.IsEmpty) return SlotContent.Empty; return SlotContent.Of(slot.ItemData, slot.Quantity, slot.CurrentUses); } /// /// Writes a snapshot back into a slot and notifies the UI. Always goes through the uses-carrying /// Set overload, so a half-worn tool stays half-worn instead of silently repairing itself. /// public void WriteSlot(int index, SlotContent content) { InventorySlot slot = GetSlot(index); if (slot == null) return; if (content.IsEmpty) slot.Clear(); else slot.Set(content.Item, content.Quantity, content.Uses); NotifySlotChanged(index); } /// /// Moves a stack between two of this inventory's slots — the drag-and-drop operation. Delegates /// the decision (move / merge / swap) to the shared rules, so it behaves exactly like a chest /// transfer. /// public void MoveSlot(int fromIndex, int toIndex) { if (fromIndex == toIndex) return; if (GetSlot(fromIndex) == null || GetSlot(toIndex) == null) return; SlotContent from = ReadSlot(fromIndex); SlotContent to = ReadSlot(toIndex); if (!SlotTransfer.Move(ref from, ref to)) return; WriteSlot(fromIndex, from); WriteSlot(toIndex, to); } /// /// Splits a stack in half into the first empty slot — half stays, half moves. No-op on an empty /// slot, a single unit, or a full inventory. It lives here rather than in the context menu so the /// split goes through the same write path as every other change; done by hand from the UI it /// bypassed NotifySlotChanged and the bus event never fired. /// public void SplitSlot(int index) { SlotContent source = ReadSlot(index); if (source.IsEmpty || source.Quantity <= 1) return; for (int i = 0; i < inventorySize; i++) { if (!slots[i].IsEmpty) continue; int moved = source.Quantity / 2; SlotContent half = SlotContent.Of(source.Item, moved, source.Uses); source.Quantity -= moved; WriteSlot(index, source); WriteSlot(i, half); return; } } /// /// Adds an item without the player picking a destination (a pickup, a craft result, a chest /// withdrawal): merges into matching stacks first, then fills empty slots. Uses-tracked items /// never stack, so each unit claims its own slot with its own uses bar; the optional uses value /// restores a partially used instance (negative = full). /// /// is the slot the player actually aimed at, tried first so a /// targeted transfer lands where they dropped it instead of the default scan silently claiming a /// hotbar slot. It is only a hint: whatever does not fit there falls through to normal placement, /// so a slot that got occupied in the meantime degrades gracefully rather than losing items. /// /// marks a stack that merely moved between the player's own /// containers (a chest withdrawal, a refund) rather than being acquired from the world, so the /// HUD does not announce a "gain" for shuffling items around. Craft discovery still counts it — /// pulling an item a co-op partner left in a chest is a genuine first acquisition. /// public bool AddItem(ItemData item, int quantity = 1, int uses = -1, int preferredIndex = -1, bool isTransfer = false) { if (item == null) { Debug.LogError("[PlayerInventory] AddItem called with no ItemData — pickup ignored.", this); return false; } SlotContent incoming = SlotContent.Of(item, quantity, uses); if (preferredIndex >= 0 && preferredIndex < inventorySize) StackInto(preferredIndex, ref incoming); for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++) if (!slots[i].IsEmpty) StackInto(i, ref incoming); for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++) if (slots[i].IsEmpty) StackInto(i, ref incoming); int added = quantity - (incoming.IsEmpty ? 0 : incoming.Quantity); if (added > 0) { onItemAdded?.Invoke(item, added); PlayerEvents.RaiseItemAdded(item, added, isTransfer); } return incoming.IsEmpty; } /// /// Pushes as much of the incoming stack as one slot accepts, through the shared auto-placement /// rule (never swaps), and writes the slot back when it changed. /// private void StackInto(int index, ref SlotContent incoming) { SlotContent target = ReadSlot(index); if (!SlotTransfer.TryStack(ref incoming, ref target)) return; WriteSlot(index, target); } /// /// Returns whether the given quantity would fit, without mutating anything. Dry-runs the exact /// same auto-placement rule the real add uses, against copies of the slots — so this answer can /// never drift from what would actually do, which a hand-written capacity /// calculation eventually would. /// public bool CanFit(ItemData item, int quantity = 1) { if (item == null) return false; SlotContent incoming = SlotContent.Of(item, quantity, -1); for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++) { if (slots[i].IsEmpty) continue; SlotContent target = ReadSlot(i); SlotTransfer.TryStack(ref incoming, ref target); } for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++) { if (!slots[i].IsEmpty) continue; SlotContent target = SlotContent.Empty; SlotTransfer.TryStack(ref incoming, ref target); } return incoming.IsEmpty; } public void RemoveItem(int index, int quantity = 1) { if (index < 0 || index >= inventorySize) return; slots[index].RemoveQuantity(quantity); NotifySlotChanged(index); } /// /// Consumes one use of a consumable: applies its restores once (per bite, for multi-use food) /// then either spends a use or removes one unit. A multi-use item is kept at 0 uses (red, /// unusable) when KeepWhenDepleted is set, otherwise it is destroyed. Ignores an already /// depleted item. /// 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 (slots[index].IsDepleted) 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); if (item.HasUses) { slots[index].ConsumeUse(1); if (slots[index].IsDepleted && !item.KeepWhenDepleted) RemoveItem(index, 1); else NotifySlotChanged(index); } else { RemoveItem(index, 1); } } /// /// Spends durability on the currently selected hotbar item after a successful tool hit. The /// tool is kept at 0 uses (red, unusable until repaired) when KeepWhenDepleted is set, otherwise /// it is destroyed. Returns false when the selected slot holds a depleted repairable tool, so /// the caller can block the swing; true otherwise (including non-uses items). /// public bool ConsumeSelectedToolUse(int amount = 1) { InventorySlot slot = slots[selectedHotbarIndex]; if (slot.IsEmpty || !slot.ItemData.HasUses) return true; if (slot.IsDepleted) return false; ItemData item = slot.ItemData; slot.ConsumeUse(amount); if (slot.IsDepleted && !item.KeepWhenDepleted) RemoveItem(selectedHotbarIndex, 1); else NotifySlotChanged(selectedHotbarIndex); return true; } public void DropItem(int index, int quantity = 1) { if (index < 0 || index >= inventorySize) return; if (slots[index].IsEmpty) return; ItemData item = slots[index].ItemData; int uses = item.HasUses ? slots[index].CurrentUses : -1; 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, uses, 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 } }