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
This commit is contained in:
2026-07-25 19:42:26 +02:00
954 changed files with 999772 additions and 26193 deletions
+176 -62
View File
@@ -3,111 +3,225 @@ using Ashwild.Player;
namespace Ashwild.Inventory
{
/// <summary>
/// Keeps the item in the player's hand in sync with the selected hotbar slot. It owns the *timing*
/// of a swap, not its look: the put-away and draw animations live in the arms rig, and this
/// controller simply waits for them.
///
/// The wait is what makes the swap read correctly. Destroying the old prefab the instant the
/// selection changes makes the item vanish mid-motion, so instead the sequence is: ask for the
/// put-away animation, wait for its Animation Event, then destroy, announce the new item (which
/// lets the animation binder load its clips first), spawn it, and ask for the draw animation.
///
/// That wait is guarded by a timeout, because the completion event only exists if someone plays the
/// clip. An item whose set has no put-away clip, a rig that was never wired, a disabled arms
/// object — any of those would otherwise leave the player stuck holding an item he already
/// switched away from, with no error to explain it. The timeout turns a silent deadlock into a
/// slightly abrupt swap.
/// </summary>
[DisallowMultipleComponent]
public class HotbarController : MonoBehaviour
{
#region Serialized Fields
[Header("References")]
[SerializeField] private Transform toolHolder;
[SerializeField] private PlayerToolHolder toolHolderAnim;
[Tooltip("Attachment joint in the arms rig's right hand (RightHand_Holder_JTN). Held prefabs are parented here so they follow the animated hand.")]
[SerializeField] private Transform handHolder;
[Tooltip("Origin and forward of the aim ray handed to held items — normally the first-person camera.")]
[SerializeField] private Transform raycastOrigin;
[Header("Swap")]
[Tooltip("Seconds to wait for the put-away animation before swapping anyway. Safety net only — a wired rig always completes first.")]
[SerializeField] private float stowTimeout = 0.5f;
#endregion
#region State
private PlayerInventory inventory;
private GameObject currentHeldObject;
private ItemData currentHeldItem;
private bool isSwitching;
private HeldItemContext heldContext;
private GameObject currentHeldObject;
private ItemData currentHeldItem;
private bool isStowing;
private ItemData pendingItem;
private float stowDeadline;
private bool stowTimeoutReported;
#endregion
#region Unity Lifecycle
/// <summary>
/// Binds to the local inventory and draws whatever is already selected, without animation —
/// spawning into the world should not look like the player just swapped weapons.
/// </summary>
private void Start()
{
inventory = PlayerInventory.Instance;
inventory.onSelectedSlotChanged.AddListener(OnSelectionChanged);
inventory.onSlotChanged.AddListener(OnSlotChanged);
if (inventory == null)
{
Debug.LogError("[HotbarController] No local PlayerInventory — the hand will stay empty.", this);
return;
}
inventory.onSelectedSlotChanged.AddListener(HandleSelectionChanged);
inventory.onSlotChanged.AddListener(HandleSlotChanged);
heldContext = new HeldItemContext { RaycastOrigin = raycastOrigin };
UpdateHeldItem(false);
}
/// <summary>
/// Listens for the put-away animation finishing. Paired with OnDisable.
/// </summary>
private void OnEnable()
{
PlayerEvents.UnequipAnimComplete += HandleUnequipAnimComplete;
}
/// <summary>
/// Unsubscribes — mirrors OnEnable exactly.
/// </summary>
private void OnDisable()
{
PlayerEvents.UnequipAnimComplete -= HandleUnequipAnimComplete;
}
/// <summary>
/// Drops the inventory listeners; the bus subscription is already handled by OnDisable.
/// </summary>
private void OnDestroy()
{
if (inventory != null)
if (inventory == null) return;
inventory.onSelectedSlotChanged.RemoveListener(HandleSelectionChanged);
inventory.onSlotChanged.RemoveListener(HandleSlotChanged);
}
/// <summary>
/// Forces the swap through when the put-away animation never reports back. Runs only while a
/// swap is actually pending, so an idle player costs nothing. The diagnostic is logged once per
/// session rather than per swap: the cause is always a wiring or authoring gap that will repeat
/// on every single hotbar change, and a console flooded with the same warning hides the rest.
/// </summary>
private void Update()
{
if (!isStowing) return;
if (Time.time < stowDeadline) return;
if (!stowTimeoutReported)
{
inventory.onSelectedSlotChanged.RemoveListener(OnSelectionChanged);
inventory.onSlotChanged.RemoveListener(OnSlotChanged);
stowTimeoutReported = true;
Debug.LogWarning("[HotbarController] The put-away animation never completed — swapping anyway " +
"(logged once). Check that the arms rig has an ArmsAnimationEvents component and " +
"that the Unequip clip carries an AnimUnequipComplete event. Until those clips " +
"exist, lower Stow Timeout so the swap stays snappy.", this);
}
CommitSwap();
}
private void OnSelectionChanged(int index)
#endregion
#region Event Handlers
private void HandleSelectionChanged(int index) => UpdateHeldItem(true);
/// <summary>
/// The held item's own stack changed (it was consumed, worn out, or refilled) — redraw only when
/// it is the selected slot.
/// </summary>
private void HandleSlotChanged(int index)
{
UpdateHeldItem(true);
if (index == inventory.SelectedHotbarIndex) UpdateHeldItem(true);
}
private void OnSlotChanged(int index)
/// <summary>
/// The arms finished putting the old item away, so the swap can complete.
/// </summary>
private void HandleUnequipAnimComplete()
{
if (index == inventory.SelectedHotbarIndex)
UpdateHeldItem(true);
if (isStowing) CommitSwap();
}
#endregion
#region Swap Sequence
/// <summary>
/// Entry point for every reason the hand might need to change. Swaps straight away when there is
/// nothing to put away (empty hands, or the very first draw), otherwise starts the put-away
/// animation and defers. A selection that changes again mid-stow only updates the target: the
/// player who scrolls three slots quickly plays one put-away, not three.
/// </summary>
private void UpdateHeldItem(bool animate)
{
InventorySlot selected = inventory.GetSelectedSlot();
ItemData newItem = selected.IsEmpty ? null : selected.ItemData;
if (newItem == currentHeldItem)
if (newItem == currentHeldItem && !isStowing) return;
pendingItem = newItem;
if (isStowing) return;
if (!animate || currentHeldObject == null)
{
CommitSwap();
return;
if (isSwitching)
{
if (currentHeldObject != null)
{
Destroy(currentHeldObject);
currentHeldObject = null;
}
isSwitching = false;
}
if (animate && currentHeldObject != null && toolHolderAnim != null)
{
isSwitching = true;
toolHolderAnim.PlayUnequipAnimation(() =>
{
if (currentHeldObject != null)
{
Destroy(currentHeldObject);
currentHeldObject = null;
}
currentHeldItem = newItem;
SpawnItem(newItem);
isSwitching = false;
if (currentHeldObject != null)
toolHolderAnim.PlayEquipAnimation();
});
}
else
{
if (currentHeldObject != null)
{
Destroy(currentHeldObject);
currentHeldObject = null;
}
currentHeldItem = newItem;
SpawnItem(newItem);
if (animate && toolHolderAnim != null && currentHeldObject != null)
toolHolderAnim.PlayEquipAnimation();
}
isStowing = true;
stowDeadline = Time.time + stowTimeout;
PlayerEvents.RaiseUnequipStarted();
}
/// <summary>
/// Performs the actual exchange. HeldItemChanged is raised *before* the new prefab is spawned so
/// the animation binder has already loaded the item's clips by the time the draw trigger fires —
/// otherwise the first frame of the draw would play the previous item's animation.
/// </summary>
private void CommitSwap()
{
isStowing = false;
if (currentHeldObject != null)
{
Destroy(currentHeldObject);
currentHeldObject = null;
}
currentHeldItem = pendingItem;
PlayerEvents.RaiseHeldItemChanged(currentHeldItem);
SpawnItem(currentHeldItem);
if (currentHeldItem != null) PlayerEvents.RaiseEquipStarted();
}
/// <summary>
/// Instantiates the item's hand prefab on the rig's hand joint and links its behaviour to the
/// player. Parenting to the joint rather than to a free-floating holder is what keeps a tool
/// locked in the palm through every animation, with no code following the hand each frame.
/// </summary>
private void SpawnItem(ItemData item)
{
if (item == null || item.HandPrefab == null || toolHolder == null)
if (item == null || item.HandPrefab == null) return;
if (handHolder == null)
{
Debug.LogError($"[HotbarController] No hand holder assigned — '{item.ItemName}' cannot be drawn. " +
"Wire the arms rig's RightHand_Holder_JTN.", this);
return;
}
currentHeldObject = Instantiate(item.HandPrefab, toolHolder);
currentHeldObject = Instantiate(item.HandPrefab, handHolder);
// Link the freshly spawned held item to the player so its behaviour
// (tool, consumable, ...) can interact — only the equipped item is alive.
IHeldItemBehaviour behaviour = currentHeldObject.GetComponentInChildren<IHeldItemBehaviour>(true);
if (behaviour != null)
behaviour.Setup(heldContext, item);
behaviour?.Setup(heldContext, item);
}
#endregion
}
}
@@ -0,0 +1,13 @@
namespace Ashwild.Inventory
{
/// <summary>
/// The tabs of the inventory window, used to address a category by name instead of by its position
/// in an inspector array — so code that opens the window on a given tab (a crafting station opening
/// straight on the craft panel) can never be silently broken by a reorder.
/// </summary>
public enum InventoryCategory
{
Inventory = 0,
Crafting = 1,
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2e1721783444e304d92ede84fdb40b7a
@@ -16,13 +16,16 @@ namespace Ashwild.Inventory
[SerializeField] private Ease unfillEase = Ease.InQuad;
private Tweener fillTween;
private int categoryIndex;
private InventoryCategory category;
public void Initialize(int index, InventoryCategoryManager manager)
/// <summary>
/// Binds this button to the category it selects on the strip manager.
/// </summary>
public void Initialize(InventoryCategory category, InventoryCategoryManager manager)
{
categoryIndex = index;
this.category = category;
fillImage.fillAmount = 0f;
button.onClick.AddListener(() => manager.SelectCategory(categoryIndex));
button.onClick.AddListener(() => manager.SelectCategory(this.category));
}
public void Select()
@@ -1,41 +1,96 @@
using System;
using UnityEngine;
namespace Ashwild.Inventory
{
/// <summary>
/// Owns the tab strip of the inventory window: which category is selected, which panel is shown, and
/// the button fill that goes with it. Categories are addressed by <see cref="InventoryCategory"/>
/// rather than by array position, so code that opens the window on a given tab (a crafting station
/// opening straight on the craft panel) stays correct if the strip is reordered.
/// </summary>
public class InventoryCategoryManager : MonoBehaviour
{
[SerializeField] private InventoryCategoryButton[] categoryButtons;
[SerializeField] private GameObject[] panels;
[SerializeField] private int defaultCategoryIndex;
#region Types
/// <summary>
/// One tab: the category it stands for, its strip button and the panel it shows. Keeping the
/// three together in one entry removes the parallel-array class of bug, where a button and a
/// panel silently drift out of alignment.
/// </summary>
[Serializable]
private class CategoryEntry
{
public InventoryCategory category;
public InventoryCategoryButton button;
public GameObject panel;
}
#endregion
#region Serialized Fields
[Header("Tabs")]
[SerializeField] private CategoryEntry[] categories;
[Header("Default")]
[Tooltip("The tab the window lands on when opened without an explicit category.")]
[SerializeField] private InventoryCategory defaultCategory = InventoryCategory.Inventory;
#endregion
#region State
private int currentIndex = -1;
private bool locked;
private void Awake()
{
for (int i = 0; i < categoryButtons.Length; i++)
{
categoryButtons[i].Initialize(i, this);
categoryButtons[i].SetFillImmediate(false);
}
#endregion
for (int i = 0; i < panels.Length; i++)
panels[i].SetActive(false);
}
#region Unity Lifecycle
/// <summary>
/// Opens the category strip on the inventory category every time — the window must always land on
/// the inventory, never stay on craft from a previous session (and chest mode needs the grid).
/// Wires each button to its category and parks every panel closed.
/// </summary>
public void Open()
private void Awake()
{
SelectCategoryImmediate(defaultCategoryIndex);
for (int i = 0; i < categories.Length; i++)
{
CategoryEntry entry = categories[i];
if (entry.button != null)
{
entry.button.Initialize(entry.category, this);
entry.button.SetFillImmediate(false);
}
if (entry.panel != null)
entry.panel.SetActive(false);
}
}
#endregion
#region Public API
/// <summary>
/// Opens the strip on the default category — the window must always land on the inventory, never
/// stay on craft from a previous session (and chest mode needs the grid).
/// </summary>
public void Open() => Open(defaultCategory);
/// <summary>
/// Opens the strip directly on the given category, bypassing the lock (this is the window
/// deciding where it lands, not the player switching tabs).
/// </summary>
public void Open(InventoryCategory category) => SelectCategoryImmediate(IndexOf(category));
/// <summary>
/// Closes every panel.
/// </summary>
public void Close()
{
for (int i = 0; i < panels.Length; i++)
panels[i].SetActive(false);
for (int i = 0; i < categories.Length; i++)
if (categories[i].panel != null)
categories[i].panel.SetActive(false);
}
/// <summary>
@@ -44,38 +99,63 @@ namespace Ashwild.Inventory
/// </summary>
public void SetLocked(bool value) => locked = value;
public void SelectCategory(int index)
/// <summary>
/// Switches to a category on the player's request (a strip button), animating the fill. Ignored
/// while locked or when the category is already shown.
/// </summary>
public void SelectCategory(InventoryCategory category)
{
if (locked) return;
if (index == currentIndex) return;
if (index < 0 || index >= panels.Length) return;
// Deselect old
if (currentIndex >= 0 && currentIndex < categoryButtons.Length)
int index = IndexOf(category);
if (index < 0 || index == currentIndex) return;
if (currentIndex >= 0)
{
categoryButtons[currentIndex].Deselect();
panels[currentIndex].SetActive(false);
if (categories[currentIndex].button != null) categories[currentIndex].button.Deselect();
if (categories[currentIndex].panel != null) categories[currentIndex].panel.SetActive(false);
}
// Select new
currentIndex = index;
categoryButtons[currentIndex].Select();
panels[currentIndex].SetActive(true);
if (categories[currentIndex].button != null) categories[currentIndex].button.Select();
if (categories[currentIndex].panel != null) categories[currentIndex].panel.SetActive(true);
}
#endregion
#region Internal Helpers
/// <summary>
/// Selects a tab without the fill animation, for the frame the window opens on.
/// </summary>
private void SelectCategoryImmediate(int index)
{
if (index < 0 || index >= panels.Length) return;
if (index < 0 || index >= categories.Length) return;
if (currentIndex >= 0 && currentIndex < categoryButtons.Length)
if (currentIndex >= 0)
{
categoryButtons[currentIndex].SetFillImmediate(false);
panels[currentIndex].SetActive(false);
if (categories[currentIndex].button != null) categories[currentIndex].button.SetFillImmediate(false);
if (categories[currentIndex].panel != null) categories[currentIndex].panel.SetActive(false);
}
currentIndex = index;
categoryButtons[currentIndex].SetFillImmediate(true);
panels[currentIndex].SetActive(true);
if (categories[currentIndex].button != null) categories[currentIndex].button.SetFillImmediate(true);
if (categories[currentIndex].panel != null) categories[currentIndex].panel.SetActive(true);
}
/// <summary>
/// Position of a category in the strip, or -1 when it was never wired — logged so a missing tab
/// is obvious instead of silently doing nothing.
/// </summary>
private int IndexOf(InventoryCategory category)
{
for (int i = 0; i < categories.Length; i++)
if (categories[i].category == category) return i;
Debug.LogError($"[InventoryCategoryManager] No tab wired for category '{category}'.", this);
return -1;
}
#endregion
}
}
+27 -2
View File
@@ -72,6 +72,13 @@ namespace Ashwild.Inventory
/// </summary>
private Chest boundChest;
/// <summary>
/// The tab the next open must land on, or null for the default. Set by whoever opens the window
/// on a specific module (a crafting station wanting the craft tab) and consumed by Show(), so it
/// applies to exactly one opening and never leaks into the next.
/// </summary>
private InventoryCategory? pendingCategory;
#endregion
#region Unity Lifecycle
@@ -175,10 +182,24 @@ namespace Ashwild.Inventory
UIManager.Instance.OpenPanel(this);
}
/// <summary>
/// Opens the window on a specific tab — used by a crafting station, which wants the craft panel
/// straight away rather than making the player click across. The tab is picked up in Show().
/// </summary>
public void OpenAtCategory(InventoryCategory category)
{
pendingCategory = category;
if (UIManager.Instance != null)
UIManager.Instance.OpenPanel(this);
}
/// <summary>
/// Opens the inventory window. Enters chest mode when a chest was bound (right side shows the
/// chest module, category is forced to and locked on the inventory), otherwise normal browsing
/// (right side shows the description). Always lands on the inventory category, never craft.
/// (right side shows the description). Lands on the tab a caller requested, or on the default
/// (inventory) otherwise — a plain open never stays on craft from a previous session, and chest
/// mode always wins since it needs the grid.
/// </summary>
public override void Show()
{
@@ -187,10 +208,14 @@ namespace Ashwild.Inventory
if (categoryManager != null)
{
categoryManager.Open();
if (!chestMode && pendingCategory.HasValue) categoryManager.Open(pendingCategory.Value);
else categoryManager.Open();
categoryManager.SetLocked(chestMode);
}
pendingCategory = null;
if (chestMode)
{
if (descriptionModule != null) descriptionModule.SetActive(false);
+18
View File
@@ -1,4 +1,5 @@
using UnityEngine;
using Ashwild.Player;
namespace Ashwild.Inventory
{
@@ -59,6 +60,12 @@ namespace Ashwild.Inventory
[SerializeField] private GameObject worldPrefab;
[SerializeField] private GameObject handPrefab;
[Header("Animation")]
[Tooltip("Clips the owner's first-person arms play while holding this item. Empty = bare-hand set.")]
[SerializeField] private PlayerAnimationSet armsAnimationSet;
[Tooltip("Clips the third-person body plays while holding this item, as seen by other players.")]
[SerializeField] private PlayerAnimationSet bodyAnimationSet;
public string ItemName => itemName;
public Sprite Icon => icon;
public string Description => description;
@@ -92,5 +99,16 @@ namespace Ashwild.Inventory
public float FuelSeconds => fuelSeconds;
public GameObject WorldPrefab => worldPrefab;
public GameObject HandPrefab => handPrefab;
/// <summary>
/// The clip set this item imposes on one of the player's two rigs. Kept as a lookup rather than
/// two public properties so a binder can be pointed at either rig from the inspector and stay
/// the same component — the arms and the body run identical code on different data.
/// Returns null when the item authors nothing, which makes the binder fall back to bare hands.
/// </summary>
public PlayerAnimationSet GetAnimationSet(PlayerAnimationRig rig)
{
return rig == PlayerAnimationRig.Arms ? armsAnimationSet : bodyAnimationSet;
}
}
}
+99 -25
View File
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using FishNet.Connection;
@@ -47,6 +48,18 @@ namespace Ashwild.Inventory
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;
@@ -156,6 +169,12 @@ namespace Ashwild.Inventory
/// <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)
@@ -167,7 +186,13 @@ namespace Ashwild.Inventory
return;
}
AddItem(item, quantity, uses, preferredIndex, isTransfer);
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
@@ -269,7 +294,19 @@ namespace Ashwild.Inventory
/// 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);
@@ -287,7 +324,9 @@ namespace Ashwild.Inventory
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);
leftover = incoming.IsEmpty ? 0 : incoming.Quantity;
int added = quantity - leftover;
if (added > 0)
{
onItemAdded?.Invoke(item, added);
@@ -317,23 +356,43 @@ namespace Ashwild.Inventory
{
if (item == null) return false;
SlotContent incoming = SlotContent.Of(item, quantity, -1);
singleFitBuffer[0] = SlotContent.Of(item, quantity, -1);
return CanFitAll(singleFitBuffer);
}
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
/// <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++)
{
if (slots[i].IsEmpty) continue;
SlotContent target = ReadSlot(i);
SlotTransfer.TryStack(ref incoming, ref target);
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;
}
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;
return true;
}
public void RemoveItem(int index, int quantity = 1)
@@ -408,21 +467,36 @@ namespace Ashwild.Inventory
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);
}
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;