Files
Emberwild/Assets/GAME/Script/Inventory/PlayerInventory.cs
Mathew 78bfdf2828 Merge remote-tracking branch 'origin/feat/inport-props' into feat/craft-discovery
# 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
2026-07-25 19:42:26 +02:00

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
}
}