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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user