using System.Collections.Generic; 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; /// /// Scratch copy of the slots used by the capacity dry run, kept as a field so a per-swing /// capacity check does not allocate. Never holds meaningful state between calls. /// private SlotContent[] fitSnapshot; /// /// One-element buffer so the single-item can reuse the multi-item dry run /// without allocating an array on every call. /// private readonly SlotContent[] singleFitBuffer = new SlotContent[1]; 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. /// /// A grant has already left its source by the time it arrives, so anything that does not fit must /// not simply evaporate the way it used to. The interactions that can be refused pre-check their /// capacity before asking the server, which makes an overflow here a last-resort case (the /// inventory filled up while the request was in flight); it is put back into the world at the /// player's feet, announced, and logged — never destroyed. /// [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, out int leftover); if (leftover <= 0) return; Debug.LogWarning($"[PlayerInventory] Inventory full — {leftover}x '{item.ItemName}' could not be " + "stored and was returned to the world.", this); PlayerEvents.RaiseInteractionRefused("Inventory full"); SpawnInWorld(item, leftover, uses); } #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) => AddItem(item, quantity, uses, preferredIndex, isTransfer, out _); /// /// Same as but reports how many units could not be /// placed. Callers that received the stack from the server need that number: whatever is left over /// has already left its source (the harvestable is damaged, the pickup is claimed) and would simply /// cease to exist if it were dropped on the floor here, which is exactly how loot used to go /// missing without a single log line. /// public bool AddItem(ItemData item, int quantity, int uses, int preferredIndex, bool isTransfer, out int leftover) { leftover = quantity; 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); leftover = incoming.IsEmpty ? 0 : incoming.Quantity; int added = quantity - leftover; 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; singleFitBuffer[0] = SlotContent.Of(item, quantity, -1); return CanFitAll(singleFitBuffer); } /// /// Returns whether several stacks would fit *together*, without mutating anything. Asking /// once per item is not equivalent and quietly over-promises: with a single /// empty slot left, two different items each answer "yes" on their own, then the second one has /// nowhere to go. Harvest loot rolls several items at once, so it needs the combined answer — the /// dry run therefore places each stack into a running snapshot of the slots, exactly as the real /// add would, and fails as soon as one leftover cannot be placed. /// public bool CanFitAll(IReadOnlyList incoming) { if (incoming == null || incoming.Count == 0) return true; if (fitSnapshot == null || fitSnapshot.Length != inventorySize) fitSnapshot = new SlotContent[inventorySize]; for (int i = 0; i < inventorySize; i++) fitSnapshot[i] = ReadSlot(i); for (int n = 0; n < incoming.Count; n++) { SlotContent pending = incoming[n]; if (pending.IsEmpty) continue; for (int i = 0; i < inventorySize && !pending.IsEmpty; i++) if (!fitSnapshot[i].IsEmpty) SlotTransfer.TryStack(ref pending, ref fitSnapshot[i]); for (int i = 0; i < inventorySize && !pending.IsEmpty; i++) if (fitSnapshot[i].IsEmpty) SlotTransfer.TryStack(ref pending, ref fitSnapshot[i]); if (!pending.IsEmpty) return false; } return true; } 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; SpawnInWorld(item, quantity, uses); RemoveItem(index, quantity); PlayerEvents.RaiseItemDropped(item, quantity); } /// /// Asks the server to spawn a stack in front of the player, without touching the inventory. Shared /// by the deliberate drop (which removes the stack first) and by the overflow safety net (whose /// stack was never stored), so both go through the same authoritative drop path. /// private void SpawnInWorld(ItemData item, int quantity, int uses) { if (item == null || quantity <= 0) return; Transform origin = dropOrigin != null ? dropOrigin : transform; if (item.WorldPrefab == null || origin == null || PickableRegistry.Instance == null) return; ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0; if (id == 0) { Debug.LogWarning($"[PlayerInventory] '{item.ItemName}' is not in the ItemDatabase — drop not spawned. Run Rebuild Item Database.", this); return; } Vector3 dropPos = origin.position + origin.forward * dropForwardDistance; PickableRegistry.Instance.RequestDropServerRpc(id, quantity, uses, dropPos, origin.rotation); } 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 } }