78bfdf2828
# Conflicts: # Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat # Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta # Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta # Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
549 lines
22 KiB
C#
549 lines
22 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.Events;
|
|
using FishNet.Connection;
|
|
using FishNet.Object;
|
|
using Ashwild.Network;
|
|
using Ashwild.Player;
|
|
|
|
namespace Ashwild.Inventory
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[DisallowMultipleComponent]
|
|
public class PlayerInventory : NetworkBehaviour
|
|
{
|
|
#region Singleton
|
|
|
|
/// <summary>
|
|
/// The local player's inventory (set only on the owning client).
|
|
/// </summary>
|
|
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<int> onSlotChanged;
|
|
public UnityEvent<int> onSelectedSlotChanged;
|
|
public UnityEvent<ItemData, int> onItemAdded;
|
|
|
|
#endregion
|
|
|
|
#region State
|
|
|
|
private InventorySlot[] slots;
|
|
private int selectedHotbarIndex;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private SlotContent[] fitSnapshot;
|
|
|
|
/// <summary>
|
|
/// One-element buffer so the single-item <see cref="CanFit"/> can reuse the multi-item dry run
|
|
/// without allocating an array on every call.
|
|
/// </summary>
|
|
private readonly SlotContent[] singleFitBuffer = new SlotContent[1];
|
|
|
|
public int InventorySize => inventorySize;
|
|
public int HotbarSize => hotbarSize;
|
|
public int SelectedHotbarIndex => selectedHotbarIndex;
|
|
|
|
#endregion
|
|
|
|
#region Unity Lifecycle
|
|
|
|
/// <summary>
|
|
/// Builds the empty slot array (local data on every copy).
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// Registers the singleton on the owning client only (before LocalPlayerSpawned fires).
|
|
/// </summary>
|
|
public override void OnStartNetwork()
|
|
{
|
|
base.OnStartNetwork();
|
|
if (base.Owner.IsLocalClient) Instance = this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the singleton when this player leaves the network.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// <paramref name="uses"/> restores a specific remaining-uses value, used when a partially used
|
|
/// instance is picked back up off the ground (negative = grant at full uses).
|
|
/// <paramref name="preferredIndex"/> 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.
|
|
/// <paramref name="isTransfer"/> marks a container-to-container move (chest) so the HUD does not
|
|
/// announce it as a gain.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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];
|
|
|
|
/// <summary>
|
|
/// Reads a slot as a container-agnostic snapshot, including its remaining uses. This is how the
|
|
/// shared <see cref="SlotTransfer"/> rules see an inventory slot.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
///
|
|
/// <paramref name="preferredIndex"/> 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.
|
|
///
|
|
/// <paramref name="isTransfer"/> 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.
|
|
/// </summary>
|
|
public bool AddItem(ItemData item, int quantity = 1, int uses = -1, int preferredIndex = -1, bool isTransfer = false)
|
|
=> AddItem(item, quantity, uses, preferredIndex, isTransfer, out _);
|
|
|
|
/// <summary>
|
|
/// Same as <see cref="AddItem(ItemData,int,int,int,bool)"/> 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void StackInto(int index, ref SlotContent incoming)
|
|
{
|
|
SlotContent target = ReadSlot(index);
|
|
if (!SlotTransfer.TryStack(ref incoming, ref target)) return;
|
|
WriteSlot(index, target);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="AddItem"/> would actually do, which a hand-written capacity
|
|
/// calculation eventually would.
|
|
/// </summary>
|
|
public bool CanFit(ItemData item, int quantity = 1)
|
|
{
|
|
if (item == null) return false;
|
|
|
|
singleFitBuffer[0] = SlotContent.Of(item, quantity, -1);
|
|
return CanFitAll(singleFitBuffer);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns whether several stacks would fit *together*, without mutating anything. Asking
|
|
/// <see cref="CanFit"/> 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.
|
|
/// </summary>
|
|
public bool CanFitAll(IReadOnlyList<SlotContent> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
}
|
|
}
|