(Feat) Add Build
This commit is contained in:
@@ -55,7 +55,8 @@ namespace Ashwild.Inventory
|
||||
{
|
||||
// Visual setup that does not need the player.
|
||||
for (int i = 0; i < hotbarSlot.Length; i++)
|
||||
hotbarSlot[i].Initialize(i, InventoryUI.OnSwapRequested, OnSlotClicked, OnSlotHoverEnter, OnSlotHoverExit);
|
||||
hotbarSlot[i].Initialize(SlotContainer.Inventory, i,
|
||||
InventoryUI.HandleSlotDrop, OnSlotClicked, OnSlotHoverEnter, OnSlotHoverExit);
|
||||
|
||||
transform.localScale = Vector3.one * idleScale;
|
||||
if (canvasGroup != null)
|
||||
@@ -154,15 +155,27 @@ namespace Ashwild.Inventory
|
||||
ZoomOut();
|
||||
}
|
||||
|
||||
private void OnSlotClicked(int index, bool rightClick)
|
||||
/// <summary>
|
||||
/// Right-click on a hotbar slot: a quick deposit into the open chest, otherwise the slot context
|
||||
/// menu (only while the inventory window is open — the hotbar must not pop a menu during play).
|
||||
/// </summary>
|
||||
private void OnSlotClicked(SlotContainer container, int index, bool rightClick)
|
||||
{
|
||||
if (rightClick && PlayerEvents.IsInventoryOpen)
|
||||
if (!rightClick) return;
|
||||
|
||||
if (PlayerEvents.IsChestOpen)
|
||||
{
|
||||
if (contextMenu != null)
|
||||
contextMenu.Show(index);
|
||||
else
|
||||
inventory.UseItem(index);
|
||||
if (InventoryUI.Instance != null)
|
||||
InventoryUI.Instance.RequestQuickTransfer(SlotContainer.Inventory, index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PlayerEvents.IsInventoryOpen) return;
|
||||
|
||||
if (contextMenu != null)
|
||||
contextMenu.Show(index);
|
||||
else
|
||||
inventory.UseItem(index);
|
||||
}
|
||||
|
||||
private void OnSlotChanged(int index)
|
||||
@@ -173,11 +186,12 @@ namespace Ashwild.Inventory
|
||||
|
||||
/// <summary>
|
||||
/// Shows the shared description panel for a hovered hotbar slot, but only while the inventory
|
||||
/// is open — during normal play the hotbar must not pop a description. The duration bar
|
||||
/// reflects the item's remaining uses/durability.
|
||||
/// is open and no chest is open (chest mode hides the description entirely) — during normal play
|
||||
/// the hotbar must not pop a description. The duration bar reflects the item's remaining uses.
|
||||
/// </summary>
|
||||
private void OnSlotHoverEnter(int index)
|
||||
private void OnSlotHoverEnter(SlotContainer container, int index)
|
||||
{
|
||||
if (PlayerEvents.IsChestOpen) return;
|
||||
if (hoverDescription == null || inventory == null || !PlayerEvents.IsInventoryOpen) return;
|
||||
|
||||
InventorySlot slot = inventory.GetSlot(index);
|
||||
@@ -187,13 +201,7 @@ namespace Ashwild.Inventory
|
||||
return;
|
||||
}
|
||||
|
||||
ItemData item = slot.ItemData;
|
||||
bool hasDuration = item.HasUses;
|
||||
float fill = hasDuration ? (float)slot.CurrentUses / item.MaxUses : 0f;
|
||||
string durationText = hasDuration ? $"{slot.CurrentUses} / {item.MaxUses}" : string.Empty;
|
||||
|
||||
hoverDescription.Show(new ItemDescriptionView(
|
||||
item.Icon, item.ItemName, item.Description, hasDuration, fill, durationText));
|
||||
hoverDescription.Show(ItemDescriptionView.From(slot));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace Ashwild.Inventory
|
||||
[SerializeField] private int defaultCategoryIndex;
|
||||
|
||||
private int currentIndex = -1;
|
||||
private bool locked;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -22,12 +23,13 @@ namespace Ashwild.Inventory
|
||||
panels[i].SetActive(false);
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// </summary>
|
||||
public void Open()
|
||||
{
|
||||
if (currentIndex < 0)
|
||||
SelectCategoryImmediate(defaultCategoryIndex);
|
||||
else
|
||||
SelectCategoryImmediate(currentIndex);
|
||||
SelectCategoryImmediate(defaultCategoryIndex);
|
||||
}
|
||||
|
||||
public void Close()
|
||||
@@ -36,8 +38,15 @@ namespace Ashwild.Inventory
|
||||
panels[i].SetActive(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks category switching while set — used in chest mode, where the left grid must stay the
|
||||
/// inventory so items can be deposited/withdrawn.
|
||||
/// </summary>
|
||||
public void SetLocked(bool value) => locked = value;
|
||||
|
||||
public void SelectCategory(int index)
|
||||
{
|
||||
if (locked) return;
|
||||
if (index == currentIndex) return;
|
||||
if (index < 0 || index >= panels.Length) return;
|
||||
|
||||
|
||||
@@ -55,17 +55,6 @@ namespace Ashwild.Inventory
|
||||
currentUses = 0;
|
||||
}
|
||||
|
||||
public int AddQuantity(int amount)
|
||||
{
|
||||
if (itemData == null || !itemData.IsStackable || itemData.HasUses)
|
||||
return amount;
|
||||
|
||||
int space = itemData.MaxStackSize - quantity;
|
||||
int toAdd = amount < space ? amount : space;
|
||||
quantity += toAdd;
|
||||
return amount - toAdd;
|
||||
}
|
||||
|
||||
public void RemoveQuantity(int amount)
|
||||
{
|
||||
quantity -= amount;
|
||||
|
||||
@@ -2,24 +2,37 @@ using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using Ashwild.Player;
|
||||
using Ashwild.Storage;
|
||||
using Ashwild.UI;
|
||||
|
||||
namespace Ashwild.Inventory
|
||||
{
|
||||
/// <summary>
|
||||
/// The inventory window. It is a local UI panel (opening/closing is purely local — the item
|
||||
/// data itself lives in the networked <see cref="PlayerInventory"/>), so it is driven by the
|
||||
/// GameUIManager panel stack like any other panel. The controller object stays active so it can
|
||||
/// build its grid when the local player spawns; Show/Hide only toggle the visual window.
|
||||
/// The inventory window and the manager of its right-side modules. It is a local UI panel
|
||||
/// (opening/closing is purely local — the item data lives in the networked <see cref="PlayerInventory"/>),
|
||||
/// driven by the GameUIManager panel stack. The left grid + hotbar are always the player's inventory;
|
||||
/// the right area swaps between two modules: the hover description (normal browsing) and the chest
|
||||
/// module (<see cref="ChestPanelUI"/>) when a chest is opened. Opening a chest reuses this same window
|
||||
/// — the inventory and hotbar cells double as the deposit source, so nothing is rebuilt — and every
|
||||
/// drop is routed here into the right operation, chest transfers going through the chest's
|
||||
/// server-authoritative RPCs so two players sharing a chest stay in sync.
|
||||
/// </summary>
|
||||
public class InventoryUI : UIPanel
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks this panel as the inventory: input is locked and the cursor shows, but the world
|
||||
/// keeps running (unlike the pause menu).
|
||||
/// keeps running (unlike the pause menu). A chest reuses this same window/kind.
|
||||
/// </summary>
|
||||
public override PanelKind Kind => PanelKind.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// The single inventory window in the scene, reached by a chest's Interact() and by slot cells
|
||||
/// routing their drops here.
|
||||
/// </summary>
|
||||
public static InventoryUI Instance { get; private set; }
|
||||
|
||||
#region Serialized Fields
|
||||
|
||||
[Header("References")]
|
||||
[SerializeField] private GameObject inventoryPanel;
|
||||
[SerializeField] private Transform slotContainer;
|
||||
@@ -33,8 +46,12 @@ namespace Ashwild.Inventory
|
||||
[Header("Context Menu")]
|
||||
[SerializeField] private SlotContextMenu contextMenu;
|
||||
|
||||
[Header("Hover Description")]
|
||||
[Header("Right Modules")]
|
||||
[Tooltip("Holder of the hover-description module — shown while browsing, hidden while a chest is open.")]
|
||||
[SerializeField] private GameObject descriptionModule;
|
||||
[SerializeField] private HoverDescriptionUI hoverDescription;
|
||||
[Tooltip("The chest module shown in place of the description while a chest is open.")]
|
||||
[SerializeField] private ChestPanelUI chestPanel;
|
||||
|
||||
[Header("Categories")]
|
||||
[SerializeField] private InventoryCategoryManager categoryManager;
|
||||
@@ -42,10 +59,32 @@ namespace Ashwild.Inventory
|
||||
[Header("Hotbar")]
|
||||
[SerializeField] private HotbarUI hotbarUI;
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
private SlotUI[] slotUIs;
|
||||
private PlayerInventory inventory;
|
||||
private bool bound;
|
||||
|
||||
/// <summary>
|
||||
/// The chest bound while the window is in chest mode (null during normal inventory browsing).
|
||||
/// </summary>
|
||||
private Chest boundChest;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Registers the singleton (in addition to the base panel setup).
|
||||
/// </summary>
|
||||
protected override void Awake()
|
||||
{
|
||||
base.Awake();
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the networked local player to spawn before building the inventory grid.
|
||||
/// </summary>
|
||||
@@ -64,7 +103,7 @@ namespace Ashwild.Inventory
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Setup shared ghost for all SlotUIs (hotbar + inventory) — does not need the player.
|
||||
// Setup shared ghost for all SlotUIs (hotbar + inventory + chest) — does not need the player.
|
||||
SlotUI.SetupGhost(ghostObject, ghostIcon, ghostQuantityText);
|
||||
inventoryPanel.SetActive(false);
|
||||
|
||||
@@ -73,6 +112,20 @@ namespace Ashwild.Inventory
|
||||
BuildInventory();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the listener and the singleton on teardown.
|
||||
/// </summary>
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (inventory != null)
|
||||
inventory.onSlotChanged.RemoveListener(RefreshSlot);
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Build
|
||||
|
||||
/// <summary>
|
||||
/// Builds the grid as soon as the local player has spawned on the network.
|
||||
/// </summary>
|
||||
@@ -96,7 +149,8 @@ namespace Ashwild.Inventory
|
||||
int slotIndex = inventory.HotbarSize + i;
|
||||
GameObject slotGO = Instantiate(slotPrefab, slotContainer);
|
||||
SlotUI slotUI = slotGO.GetComponent<SlotUI>();
|
||||
slotUI.Initialize(slotIndex, OnSwapRequested, OnSlotClicked, OnSlotHoverEnter, OnSlotHoverExit);
|
||||
slotUI.Initialize(SlotContainer.Inventory, slotIndex,
|
||||
HandleSlotDrop, OnSlotClicked, OnSlotHoverEnter, OnSlotHoverExit);
|
||||
slotUIs[i] = slotUI;
|
||||
}
|
||||
|
||||
@@ -104,29 +158,59 @@ namespace Ashwild.Inventory
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
#endregion
|
||||
|
||||
#region Panel Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Binds a chest and opens this window in chest mode through the panel stack. Called from a
|
||||
/// chest's Interact(); the chest is picked up in Show().
|
||||
/// </summary>
|
||||
public void OpenChest(Chest chest)
|
||||
{
|
||||
if (inventory != null)
|
||||
inventory.onSlotChanged.RemoveListener(RefreshSlot);
|
||||
if (chest == null) return;
|
||||
boundChest = chest;
|
||||
|
||||
if (UIManager.Instance != null)
|
||||
UIManager.Instance.OpenPanel(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the inventory window (called by the GameUIManager panel stack).
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public override void Show()
|
||||
{
|
||||
inventoryPanel.SetActive(true);
|
||||
RefreshAll();
|
||||
bool chestMode = boundChest != null;
|
||||
|
||||
if (categoryManager != null)
|
||||
{
|
||||
categoryManager.Open();
|
||||
categoryManager.SetLocked(chestMode);
|
||||
}
|
||||
|
||||
if (chestMode)
|
||||
{
|
||||
if (descriptionModule != null) descriptionModule.SetActive(false);
|
||||
if (chestPanel != null) chestPanel.Bind(boundChest);
|
||||
PlayerEvents.RaiseChestOpenChanged(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (chestPanel != null) chestPanel.Hide();
|
||||
if (descriptionModule != null) descriptionModule.SetActive(true);
|
||||
}
|
||||
|
||||
RefreshAll();
|
||||
|
||||
if (hotbarUI != null)
|
||||
hotbarUI.OnInventoryOpen();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the inventory window and tears down its transient UI (ghost, context menu).
|
||||
/// Closes the window and tears down its transient UI (ghost, context menu, chest binding).
|
||||
/// </summary>
|
||||
public override void Hide()
|
||||
{
|
||||
@@ -140,11 +224,16 @@ namespace Ashwild.Inventory
|
||||
hoverDescription.Hide();
|
||||
|
||||
if (categoryManager != null)
|
||||
{
|
||||
categoryManager.SetLocked(false);
|
||||
categoryManager.Close();
|
||||
}
|
||||
|
||||
if (hotbarUI != null)
|
||||
hotbarUI.OnInventoryClose();
|
||||
|
||||
CloseChestMode();
|
||||
|
||||
inventoryPanel.SetActive(false);
|
||||
}
|
||||
|
||||
@@ -156,54 +245,99 @@ namespace Ashwild.Inventory
|
||||
if (ghostObject != null)
|
||||
ghostObject.SetActive(false);
|
||||
|
||||
CloseChestMode();
|
||||
|
||||
inventoryPanel.SetActive(false);
|
||||
}
|
||||
|
||||
// Called by any SlotUI (inventory or hotbar) when a drag-drop completes
|
||||
public static void OnSwapRequested(int fromIndex, int toIndex)
|
||||
/// <summary>
|
||||
/// Leaves chest mode: hides the chest module, releases the chest and puts the description module
|
||||
/// back. Shared by both close paths so they can never drift apart — HideInstant used to forget
|
||||
/// the description and left the right side blank on the next open.
|
||||
/// </summary>
|
||||
private void CloseChestMode()
|
||||
{
|
||||
PlayerInventory inv = PlayerInventory.Instance;
|
||||
InventorySlot fromSlot = inv.GetSlot(fromIndex);
|
||||
InventorySlot toSlot = inv.GetSlot(toIndex);
|
||||
if (boundChest == null) return;
|
||||
|
||||
// Same stackable item: merge
|
||||
if (!fromSlot.IsEmpty && !toSlot.IsEmpty
|
||||
&& fromSlot.ItemData == toSlot.ItemData
|
||||
&& toSlot.CanAccept(fromSlot.ItemData))
|
||||
{
|
||||
int leftover = toSlot.AddQuantity(fromSlot.Quantity);
|
||||
if (leftover <= 0)
|
||||
fromSlot.Clear();
|
||||
else
|
||||
fromSlot.Set(fromSlot.ItemData, leftover);
|
||||
|
||||
inv.onSlotChanged?.Invoke(fromIndex);
|
||||
inv.onSlotChanged?.Invoke(toIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Swap
|
||||
inv.SwapSlots(fromIndex, toIndex);
|
||||
}
|
||||
if (chestPanel != null) chestPanel.Hide();
|
||||
boundChest = null;
|
||||
if (descriptionModule != null) descriptionModule.SetActive(true);
|
||||
PlayerEvents.RaiseChestOpenChanged(false);
|
||||
}
|
||||
|
||||
private void OnSlotClicked(int index, bool rightClick)
|
||||
#endregion
|
||||
|
||||
#region Transfer Routing
|
||||
|
||||
/// <summary>
|
||||
/// Routes a slot drop to whoever owns the slots: the inventory itself when both ends are local,
|
||||
/// otherwise the chest (which reconciles it server-side). Both paths end up in the same
|
||||
/// <see cref="SlotTransfer.Move"/> rules, so a chest drag behaves exactly like an inventory drag.
|
||||
/// Static so every cell (inventory, hotbar, chest) reports here without a per-cell reference.
|
||||
/// </summary>
|
||||
public static void HandleSlotDrop(SlotContainer fromContainer, int fromIndex, SlotContainer toContainer, int toIndex)
|
||||
{
|
||||
if (rightClick)
|
||||
if (fromContainer == SlotContainer.Inventory && toContainer == SlotContainer.Inventory)
|
||||
{
|
||||
if (contextMenu != null)
|
||||
contextMenu.Show(index);
|
||||
else
|
||||
inventory.UseItem(index);
|
||||
PlayerInventory inv = PlayerInventory.Instance;
|
||||
if (inv != null) inv.MoveSlot(fromIndex, toIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
Chest chest = Instance != null ? Instance.boundChest : null;
|
||||
if (chest == null) return;
|
||||
|
||||
// Paint the chest cell as emptied now: its clear replicates on the SyncList (end of tick)
|
||||
// while the grant is an immediate TargetRpc, so without this the stack visibly lands in the
|
||||
// inventory before it leaves the chest.
|
||||
if (fromContainer == SlotContainer.Chest && Instance.chestPanel != null)
|
||||
Instance.chestPanel.PredictEmptied(fromIndex);
|
||||
|
||||
chest.RequestMove(fromContainer, fromIndex, toContainer, toIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the description payload for the hovered slot and shows the side panel. Empty slots
|
||||
/// keep the panel hidden. The duration bar reflects the item's remaining uses/durability.
|
||||
/// Quick-transfers a stack to the other container (auto-placed), for right-clicks. Exposed so the
|
||||
/// hotbar — which cannot reach the private chest binding — can route its own cells here.
|
||||
/// </summary>
|
||||
private void OnSlotHoverEnter(int index)
|
||||
public void RequestQuickTransfer(SlotContainer container, int index)
|
||||
{
|
||||
if (boundChest != null)
|
||||
boundChest.RequestQuickTransfer(container, index);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
/// <summary>
|
||||
/// Right-click on an inventory cell: a quick deposit while a chest is open, otherwise the slot
|
||||
/// context menu.
|
||||
/// </summary>
|
||||
private void OnSlotClicked(SlotContainer container, int index, bool rightClick)
|
||||
{
|
||||
if (!rightClick) return;
|
||||
|
||||
if (boundChest != null)
|
||||
{
|
||||
boundChest.RequestQuickTransfer(SlotContainer.Inventory, index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (contextMenu != null)
|
||||
contextMenu.Show(index);
|
||||
else if (inventory != null)
|
||||
inventory.UseItem(index);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the description payload for the hovered slot and shows the side panel. Suppressed in
|
||||
/// chest mode (the description module is hidden) and for empty slots. The duration bar reflects
|
||||
/// the item's remaining uses/durability.
|
||||
/// </summary>
|
||||
private void OnSlotHoverEnter(SlotContainer container, int index)
|
||||
{
|
||||
if (boundChest != null) return;
|
||||
if (hoverDescription == null || inventory == null) return;
|
||||
|
||||
InventorySlot slot = inventory.GetSlot(index);
|
||||
@@ -213,13 +347,7 @@ namespace Ashwild.Inventory
|
||||
return;
|
||||
}
|
||||
|
||||
ItemData item = slot.ItemData;
|
||||
bool hasDuration = item.HasUses;
|
||||
float fill = hasDuration ? (float)slot.CurrentUses / item.MaxUses : 0f;
|
||||
string durationText = hasDuration ? $"{slot.CurrentUses} / {item.MaxUses}" : string.Empty;
|
||||
|
||||
hoverDescription.Show(new ItemDescriptionView(
|
||||
item.Icon, item.ItemName, item.Description, hasDuration, fill, durationText));
|
||||
hoverDescription.Show(ItemDescriptionView.From(slot));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -231,6 +359,10 @@ namespace Ashwild.Inventory
|
||||
hoverDescription.Hide();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Refresh
|
||||
|
||||
private void RefreshSlot(int index)
|
||||
{
|
||||
if (slotUIs == null) return;
|
||||
@@ -250,5 +382,7 @@ namespace Ashwild.Inventory
|
||||
slotUIs[i].UpdateVisual(inventory.GetSlot(slotIndex));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,5 +39,21 @@ namespace Ashwild.Inventory
|
||||
DurationFill = durationFill;
|
||||
DurationText = durationText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the payload for a hovered slot, including the uses bar for items that track
|
||||
/// durability. Shared by the inventory grid and the hotbar so both describe an item
|
||||
/// identically — they used to each build this by hand and could drift apart.
|
||||
/// </summary>
|
||||
public static ItemDescriptionView From(InventorySlot slot)
|
||||
{
|
||||
ItemData item = slot.ItemData;
|
||||
bool hasDuration = item.HasUses;
|
||||
float fill = hasDuration && item.MaxUses > 0 ? (float)slot.CurrentUses / item.MaxUses : 0f;
|
||||
string durationText = hasDuration ? $"{slot.CurrentUses} / {item.MaxUses}" : string.Empty;
|
||||
|
||||
return new ItemDescriptionView(item.Icon, item.ItemName, item.Description,
|
||||
hasDuration, fill, durationText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,15 +130,16 @@ namespace Ashwild.Inventory
|
||||
|
||||
/// <summary>
|
||||
/// Server-side: sends a granted item to this inventory's owning client, where it is added.
|
||||
/// Called after the server authorises a pickup.
|
||||
/// 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) => GrantItemFromServer(item, quantity, -1);
|
||||
|
||||
/// <summary>
|
||||
/// Server-side grant that also restores a specific remaining-uses value, used when a partially
|
||||
/// used instance is picked back up off the ground. A negative value means "grant at full uses".
|
||||
/// </summary>
|
||||
public void GrantItemFromServer(ItemData item, int quantity, int uses)
|
||||
public void GrantItemFromServer(ItemData item, int quantity = 1, int uses = -1, int preferredIndex = -1, bool isTransfer = false)
|
||||
{
|
||||
if (item == null) return;
|
||||
|
||||
@@ -149,15 +150,15 @@ namespace Ashwild.Inventory
|
||||
return;
|
||||
}
|
||||
|
||||
TargetGrantItem(Owner, id, quantity, uses);
|
||||
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 when one was carried across the drop.
|
||||
/// remaining uses and honouring the slot the player aimed at when one was requested.
|
||||
/// </summary>
|
||||
[TargetRpc]
|
||||
private void TargetGrantItem(NetworkConnection conn, ushort itemId, int quantity, int uses)
|
||||
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)
|
||||
@@ -166,7 +167,7 @@ namespace Ashwild.Inventory
|
||||
return;
|
||||
}
|
||||
|
||||
AddItem(item, quantity, uses);
|
||||
AddItem(item, quantity, uses, preferredIndex, isTransfer);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -182,11 +183,92 @@ namespace Ashwild.Inventory
|
||||
public InventorySlot GetSelectedSlot() => slots[selectedHotbarIndex];
|
||||
|
||||
/// <summary>
|
||||
/// Adds an item, stacking into existing slots first. Uses-tracked items never stack: each unit
|
||||
/// takes its own slot with its own uses bar. The optional uses value restores a partially used
|
||||
/// instance (negative = full); it only applies to uses-tracked items.
|
||||
/// 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 bool AddItem(ItemData item, int quantity = 1, int uses = -1)
|
||||
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)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
@@ -194,66 +276,64 @@ namespace Ashwild.Inventory
|
||||
return false;
|
||||
}
|
||||
|
||||
int remaining = quantity;
|
||||
SlotContent incoming = SlotContent.Of(item, quantity, uses);
|
||||
|
||||
if (!item.HasUses)
|
||||
{
|
||||
for (int i = 0; i < inventorySize && remaining > 0; i++)
|
||||
{
|
||||
if (!slots[i].IsEmpty && slots[i].ItemData == item && slots[i].CanAccept(item))
|
||||
{
|
||||
remaining = slots[i].AddQuantity(remaining);
|
||||
NotifySlotChanged(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < inventorySize && remaining > 0; i++)
|
||||
{
|
||||
if (slots[i].IsEmpty)
|
||||
{
|
||||
if (item.HasUses)
|
||||
{
|
||||
slots[i].Set(item, 1, uses);
|
||||
remaining -= 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
int toPlace = (item.IsStackable && remaining > item.MaxStackSize) ? item.MaxStackSize : remaining;
|
||||
slots[i].Set(item, toPlace);
|
||||
remaining -= toPlace;
|
||||
}
|
||||
NotifySlotChanged(i);
|
||||
}
|
||||
}
|
||||
if (preferredIndex >= 0 && preferredIndex < inventorySize)
|
||||
StackInto(preferredIndex, ref incoming);
|
||||
|
||||
int added = quantity - remaining;
|
||||
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);
|
||||
|
||||
int added = quantity - (incoming.IsEmpty ? 0 : incoming.Quantity);
|
||||
if (added > 0)
|
||||
{
|
||||
onItemAdded?.Invoke(item, added);
|
||||
PlayerEvents.RaiseItemAdded(item, added);
|
||||
PlayerEvents.RaiseItemAdded(item, added, isTransfer);
|
||||
}
|
||||
return remaining <= 0;
|
||||
return incoming.IsEmpty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the given quantity of an item would fit without mutating anything.
|
||||
/// 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;
|
||||
|
||||
int remaining = quantity;
|
||||
for (int i = 0; i < inventorySize && remaining > 0; i++)
|
||||
SlotContent incoming = SlotContent.Of(item, quantity, -1);
|
||||
|
||||
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
|
||||
{
|
||||
if (!slots[i].IsEmpty && slots[i].ItemData == item && slots[i].CanAccept(item))
|
||||
remaining -= Mathf.Min(remaining, item.MaxStackSize - slots[i].Quantity);
|
||||
if (slots[i].IsEmpty) continue;
|
||||
SlotContent target = ReadSlot(i);
|
||||
SlotTransfer.TryStack(ref incoming, ref target);
|
||||
}
|
||||
for (int i = 0; i < inventorySize && remaining > 0; i++)
|
||||
|
||||
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
|
||||
{
|
||||
if (slots[i].IsEmpty)
|
||||
remaining -= Mathf.Min(remaining, item.IsStackable ? item.MaxStackSize : 1);
|
||||
if (!slots[i].IsEmpty) continue;
|
||||
SlotContent target = SlotContent.Empty;
|
||||
SlotTransfer.TryStack(ref incoming, ref target);
|
||||
}
|
||||
return remaining <= 0;
|
||||
|
||||
return incoming.IsEmpty;
|
||||
}
|
||||
|
||||
public void RemoveItem(int index, int quantity = 1)
|
||||
@@ -263,24 +343,6 @@ namespace Ashwild.Inventory
|
||||
NotifySlotChanged(index);
|
||||
}
|
||||
|
||||
public void SwapSlots(int indexA, int indexB)
|
||||
{
|
||||
if (indexA < 0 || indexA >= inventorySize) return;
|
||||
if (indexB < 0 || indexB >= inventorySize) return;
|
||||
|
||||
ItemData tempData = slots[indexA].ItemData;
|
||||
int tempQty = slots[indexA].Quantity;
|
||||
|
||||
if (slots[indexB].IsEmpty) slots[indexA].Clear();
|
||||
else slots[indexA].Set(slots[indexB].ItemData, slots[indexB].Quantity);
|
||||
|
||||
if (tempData == null) slots[indexB].Clear();
|
||||
else slots[indexB].Set(tempData, tempQty);
|
||||
|
||||
NotifySlotChanged(indexA);
|
||||
NotifySlotChanged(indexB);
|
||||
}
|
||||
|
||||
/// <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,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Ashwild.Inventory
|
||||
{
|
||||
/// <summary>
|
||||
/// A container-agnostic snapshot of what a slot holds: the item, how many, and the remaining uses
|
||||
/// of this concrete instance (-1 when the item does not track uses). It is the common currency
|
||||
/// between every container — the player's inventory, a chest, or a stack in flight over the network —
|
||||
/// so the transfer rules can be written once without knowing where a slot lives.
|
||||
///
|
||||
/// Carrying <see cref="Uses"/> is what keeps a half-worn tool half-worn when it changes slot: writing
|
||||
/// a slot back without it silently repairs the item to full.
|
||||
/// </summary>
|
||||
public struct SlotContent
|
||||
{
|
||||
public ItemData Item;
|
||||
public int Quantity;
|
||||
public int Uses;
|
||||
|
||||
/// <summary>
|
||||
/// True when this snapshot holds nothing — no item, or a quantity that ran out.
|
||||
/// </summary>
|
||||
public bool IsEmpty => Item == null || Quantity <= 0;
|
||||
|
||||
/// <summary>
|
||||
/// The empty snapshot, used to clear a slot.
|
||||
/// </summary>
|
||||
public static SlotContent Empty => new SlotContent { Item = null, Quantity = 0, Uses = -1 };
|
||||
|
||||
/// <summary>
|
||||
/// Builds a snapshot, normalising the uses of an item that does not track them to -1.
|
||||
/// </summary>
|
||||
public static SlotContent Of(ItemData item, int quantity, int uses)
|
||||
{
|
||||
if (item == null || quantity <= 0) return Empty;
|
||||
return new SlotContent
|
||||
{
|
||||
Item = item,
|
||||
Quantity = quantity,
|
||||
Uses = item.HasUses ? uses : -1
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5c5314c94623af7498a750b916e5c22f
|
||||
@@ -226,7 +226,7 @@ namespace Ashwild.Inventory
|
||||
}
|
||||
}
|
||||
|
||||
InventoryUI.OnSwapRequested(currentSlotIndex, targetSlot);
|
||||
inv.MoveSlot(currentSlotIndex, targetSlot);
|
||||
}
|
||||
|
||||
private void OnMoveToInventory()
|
||||
@@ -238,7 +238,7 @@ namespace Ashwild.Inventory
|
||||
{
|
||||
if (inv.GetSlot(i).IsEmpty)
|
||||
{
|
||||
InventoryUI.OnSwapRequested(currentSlotIndex, i);
|
||||
inv.MoveSlot(currentSlotIndex, i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ namespace Ashwild.Inventory
|
||||
InventorySlot slot = inv.GetSlot(i);
|
||||
if (!slot.IsEmpty && slot.ItemData == sourceSlot.ItemData && slot.CanAccept(sourceSlot.ItemData))
|
||||
{
|
||||
InventoryUI.OnSwapRequested(currentSlotIndex, i);
|
||||
inv.MoveSlot(currentSlotIndex, i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -273,24 +273,7 @@ namespace Ashwild.Inventory
|
||||
private void OnSplit()
|
||||
{
|
||||
if (currentSlotIndex < 0) return;
|
||||
PlayerInventory inv = PlayerInventory.Instance;
|
||||
InventorySlot slot = inv.GetSlot(currentSlotIndex);
|
||||
if (slot.IsEmpty || slot.Quantity <= 1) return;
|
||||
|
||||
int halfQty = slot.Quantity / 2;
|
||||
int remaining = slot.Quantity - halfQty;
|
||||
|
||||
for (int i = 0; i < inv.InventorySize; i++)
|
||||
{
|
||||
if (inv.GetSlot(i).IsEmpty)
|
||||
{
|
||||
slot.Set(slot.ItemData, remaining);
|
||||
inv.GetSlot(i).Set(slot.ItemData, halfQty);
|
||||
inv.onSlotChanged?.Invoke(currentSlotIndex);
|
||||
inv.onSlotChanged?.Invoke(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
PlayerInventory.Instance.SplitSlot(currentSlotIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ashwild.Inventory
|
||||
{
|
||||
/// <summary>
|
||||
/// The single source of truth for what happens when a stack meets another stack. Both rules below
|
||||
/// are pure — they know nothing about containers, networking or authority — so the exact same
|
||||
/// decision runs for an inventory-to-inventory drag (client-side), a chest-to-chest drag
|
||||
/// (server-side) and an inventory-to-chest drag (server-side, against the payload in flight).
|
||||
/// That is why a chest transfer behaves identically to moving an item inside the inventory.
|
||||
///
|
||||
/// There are exactly two operations, because the player expresses exactly two intents:
|
||||
/// <see cref="Move"/> when they aim at a precise destination, and <see cref="TryStack"/> when they
|
||||
/// let the game find a free spot (quick transfer, deposit-all, pickups).
|
||||
/// </summary>
|
||||
public static class SlotTransfer
|
||||
{
|
||||
/// <summary>
|
||||
/// Moves a stack onto a destination the player explicitly aimed at. Empty destination = the whole
|
||||
/// stack moves; same mergeable item = merge and leave the remainder in the source; anything else
|
||||
/// (different item, non-stackable, uses-tracked, or a full stack) = swap the two slots. Uses ride
|
||||
/// along with the content in every branch, so a worn item stays worn.
|
||||
/// Returns whether anything actually changed.
|
||||
/// </summary>
|
||||
public static bool Move(ref SlotContent source, ref SlotContent target)
|
||||
{
|
||||
if (source.IsEmpty) return false;
|
||||
|
||||
if (target.IsEmpty)
|
||||
{
|
||||
target = source;
|
||||
source = SlotContent.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (CanMerge(source, target))
|
||||
{
|
||||
int space = target.Item.MaxStackSize - target.Quantity;
|
||||
int moved = Mathf.Min(source.Quantity, space);
|
||||
|
||||
target.Quantity += moved;
|
||||
source.Quantity -= moved;
|
||||
if (source.Quantity <= 0) source = SlotContent.Empty;
|
||||
|
||||
return moved > 0;
|
||||
}
|
||||
|
||||
SlotContent temp = source;
|
||||
source = target;
|
||||
target = temp;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-placement: pushes as much of <paramref name="incoming"/> into the target as it can accept,
|
||||
/// and never swaps — the player did not pick this slot, so displacing what is already there would
|
||||
/// be wrong. An empty target takes one instance of a uses-tracked item (each keeps its own uses
|
||||
/// bar) or up to a full stack otherwise. Returns whether the target changed, leaving the leftover
|
||||
/// in <paramref name="incoming"/> for the caller to keep placing.
|
||||
/// </summary>
|
||||
public static bool TryStack(ref SlotContent incoming, ref SlotContent target)
|
||||
{
|
||||
if (incoming.IsEmpty) return false;
|
||||
|
||||
ItemData item = incoming.Item;
|
||||
|
||||
if (target.IsEmpty)
|
||||
{
|
||||
int toPlace = item.HasUses || !item.IsStackable
|
||||
? 1
|
||||
: Mathf.Min(incoming.Quantity, item.MaxStackSize);
|
||||
|
||||
target = SlotContent.Of(item, toPlace, incoming.Uses);
|
||||
incoming.Quantity -= toPlace;
|
||||
if (incoming.Quantity <= 0) incoming = SlotContent.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!CanMerge(incoming, target)) return false;
|
||||
|
||||
int space = target.Item.MaxStackSize - target.Quantity;
|
||||
int moved = Mathf.Min(incoming.Quantity, space);
|
||||
if (moved <= 0) return false;
|
||||
|
||||
target.Quantity += moved;
|
||||
incoming.Quantity -= moved;
|
||||
if (incoming.Quantity <= 0) incoming = SlotContent.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two stacks merge only when they are the same stackable item that does not track uses and the
|
||||
/// target still has room. Uses-tracked items never merge: each instance owns its uses bar, so
|
||||
/// merging them would silently destroy one item's wear.
|
||||
/// </summary>
|
||||
private static bool CanMerge(SlotContent source, SlotContent target)
|
||||
{
|
||||
ItemData item = source.Item;
|
||||
return target.Item == item
|
||||
&& item.IsStackable
|
||||
&& !item.HasUses
|
||||
&& target.Quantity < item.MaxStackSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ca211917655c26a4189545ea5e5ec0a8
|
||||
@@ -6,8 +6,26 @@ using System;
|
||||
|
||||
namespace Ashwild.Inventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Which container a slot cell maps to, so a drop can be routed to the right operation: a plain
|
||||
/// inventory swap, or a deposit/withdraw/move when a chest is involved.
|
||||
/// </summary>
|
||||
public enum SlotContainer
|
||||
{
|
||||
Inventory,
|
||||
Chest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One draggable slot cell, used everywhere the same way: the inventory grid, the hotbar and the
|
||||
/// chest module. It is a dumb view — it renders whatever it is handed and reports its drags, clicks
|
||||
/// and hovers back to a manager as a (container, index) pair; it never mutates any model itself.
|
||||
/// The manager turns a drop into the matching operation (swap, deposit, withdraw, move).
|
||||
/// </summary>
|
||||
public class SlotUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler
|
||||
{
|
||||
#region Serialized Fields
|
||||
|
||||
[Header("References")]
|
||||
[SerializeField] private Image iconImage;
|
||||
[SerializeField] private TextMeshProUGUI quantityText;
|
||||
@@ -23,137 +41,197 @@ namespace Ashwild.Inventory
|
||||
[Tooltip("Icon (and bar) tint applied when the item is depleted — a broken repairable tool / empty container.")]
|
||||
[SerializeField] private Color depletedTint = Color.red;
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
private SlotContainer container;
|
||||
private int slotIndex;
|
||||
private Action<int, int> onSwapRequested;
|
||||
private Action<int, bool> onClicked;
|
||||
private Action<int> onHoverEnter;
|
||||
private bool hasItem;
|
||||
|
||||
private Action<SlotContainer, int, SlotContainer, int> onDrop;
|
||||
private Action<SlotContainer, int, bool> onClicked;
|
||||
private Action<SlotContainer, int> onHoverEnter;
|
||||
private Action onHoverExit;
|
||||
|
||||
public int SlotIndex => slotIndex;
|
||||
public SlotContainer Container => container;
|
||||
|
||||
// Static drag state shared across all slots
|
||||
// Static drag state shared across every cell (inventory, hotbar and chest grids).
|
||||
private static SlotUI draggedSlot;
|
||||
private static GameObject ghostObject;
|
||||
private static Image ghostIcon;
|
||||
private static TextMeshProUGUI ghostQuantity;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Setup
|
||||
|
||||
/// <summary>
|
||||
/// Wires the single shared drag ghost used by every slot cell in the scene.
|
||||
/// </summary>
|
||||
public static void SetupGhost(GameObject ghost, Image icon, TextMeshProUGUI qty)
|
||||
{
|
||||
ghostObject = ghost;
|
||||
ghostIcon = icon;
|
||||
ghostQuantity = qty;
|
||||
ghostObject.SetActive(false);
|
||||
if (ghostObject != null) ghostObject.SetActive(false);
|
||||
}
|
||||
|
||||
public void Initialize(int index, Action<int, int> swapCallback, Action<int, bool> clickCallback,
|
||||
Action<int> hoverEnterCallback = null, Action hoverExitCallback = null)
|
||||
/// <summary>
|
||||
/// Binds this cell to a container and index and the manager callbacks it reports interactions to.
|
||||
/// Drops carry both the dragged and the target (container, index) so the manager can route them.
|
||||
/// </summary>
|
||||
public void Initialize(SlotContainer slotContainer, int index,
|
||||
Action<SlotContainer, int, SlotContainer, int> dropCallback,
|
||||
Action<SlotContainer, int, bool> clickCallback,
|
||||
Action<SlotContainer, int> hoverEnterCallback = null,
|
||||
Action hoverExitCallback = null)
|
||||
{
|
||||
container = slotContainer;
|
||||
slotIndex = index;
|
||||
onSwapRequested = swapCallback;
|
||||
onDrop = dropCallback;
|
||||
onClicked = clickCallback;
|
||||
onHoverEnter = hoverEnterCallback;
|
||||
onHoverExit = hoverExitCallback;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rendering
|
||||
|
||||
/// <summary>
|
||||
/// Redraws the slot: icon, quantity, and the uses bar. Uses-tracked items (tools, multi-bite
|
||||
/// food) show a fill bar for their remaining uses and turn red once depleted.
|
||||
/// Redraws the cell from an inventory slot (the local, client-authoritative container).
|
||||
/// </summary>
|
||||
public void UpdateVisual(InventorySlot slot)
|
||||
{
|
||||
if (slot == null || slot.IsEmpty)
|
||||
{
|
||||
iconImage.gameObject.SetActive(false);
|
||||
quantityText.gameObject.SetActive(false);
|
||||
if (usageBarRoot != null) usageBarRoot.SetActive(false);
|
||||
RenderEmpty();
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
iconImage.gameObject.SetActive(true);
|
||||
iconImage.sprite = slot.ItemData.Icon;
|
||||
bool showQty = slot.Quantity > 1;
|
||||
quantityText.gameObject.SetActive(showQty);
|
||||
if (showQty)
|
||||
quantityText.text = slot.Quantity.ToString();
|
||||
|
||||
UpdateUsageBar(slot);
|
||||
}
|
||||
ItemData item = slot.ItemData;
|
||||
bool hasUses = item.HasUses;
|
||||
float fill = hasUses && item.MaxUses > 0 ? (float)slot.CurrentUses / item.MaxUses : 0f;
|
||||
RenderItem(item.Icon, slot.Quantity, hasUses, hasUses && slot.IsDepleted, fill);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the uses bar and tints the icon red when the item is depleted; hides the bar entirely
|
||||
/// for items that do not track uses. Preserves the current icon alpha so a mid-drag fade stays.
|
||||
/// Redraws the cell from a resolved chest view (item + quantity + remaining uses). A null item
|
||||
/// renders the empty look. Uses -1 for items that do not track uses.
|
||||
/// </summary>
|
||||
private void UpdateUsageBar(InventorySlot slot)
|
||||
public void UpdateVisual(ItemData item, int quantity, int uses)
|
||||
{
|
||||
bool hasUses = slot.ItemData.HasUses;
|
||||
if (item == null)
|
||||
{
|
||||
RenderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
if (usageBarRoot != null)
|
||||
usageBarRoot.SetActive(hasUses);
|
||||
bool hasUses = item.HasUses;
|
||||
bool depleted = hasUses && uses <= 0;
|
||||
float fill = hasUses && item.MaxUses > 0 ? (float)Mathf.Max(0, uses) / item.MaxUses : 0f;
|
||||
RenderItem(item.Icon, quantity, hasUses, depleted, fill);
|
||||
}
|
||||
|
||||
Color tint = (hasUses && slot.IsDepleted) ? depletedTint : normalTint;
|
||||
/// <summary>
|
||||
/// Draws an item: icon, quantity (hidden for single stacks) and the uses bar. Preserves the
|
||||
/// current icon alpha so a mid-drag fade survives a refresh, and tints the icon red when
|
||||
/// depleted. Shared by both the inventory and the chest render paths.
|
||||
/// </summary>
|
||||
private void RenderItem(Sprite icon, int quantity, bool hasUses, bool depleted, float fill)
|
||||
{
|
||||
hasItem = true;
|
||||
|
||||
iconImage.gameObject.SetActive(true);
|
||||
iconImage.sprite = icon;
|
||||
|
||||
bool showQty = quantity > 1;
|
||||
quantityText.gameObject.SetActive(showQty);
|
||||
if (showQty) quantityText.text = quantity.ToString();
|
||||
|
||||
if (usageBarRoot != null) usageBarRoot.SetActive(hasUses);
|
||||
|
||||
Color tint = depleted ? depletedTint : normalTint;
|
||||
tint.a = iconImage.color.a;
|
||||
iconImage.color = tint;
|
||||
|
||||
if (hasUses && usageBarFill != null)
|
||||
usageBarFill.fillAmount = (float)slot.CurrentUses / slot.ItemData.MaxUses;
|
||||
if (hasUses && usageBarFill != null) usageBarFill.fillAmount = fill;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cell to its empty look.
|
||||
/// </summary>
|
||||
private void RenderEmpty()
|
||||
{
|
||||
hasItem = false;
|
||||
iconImage.gameObject.SetActive(false);
|
||||
quantityText.gameObject.SetActive(false);
|
||||
if (usageBarRoot != null) usageBarRoot.SetActive(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the selection highlight (used by the hotbar for the active slot).
|
||||
/// </summary>
|
||||
public void SetSelected(bool selected)
|
||||
{
|
||||
if (highlightImage != null)
|
||||
highlightImage.gameObject.SetActive(selected);
|
||||
}
|
||||
|
||||
// Drag & Drop
|
||||
#endregion
|
||||
|
||||
#region Drag & Drop
|
||||
|
||||
/// <summary>
|
||||
/// Starts dragging this cell's stack (left button, non-empty only), building the shared ghost
|
||||
/// from what the cell currently shows — container-agnostic, so inventory and chest cells drag
|
||||
/// identically.
|
||||
/// </summary>
|
||||
public void OnBeginDrag(PointerEventData eventData)
|
||||
{
|
||||
if (eventData.button != PointerEventData.InputButton.Left) return;
|
||||
|
||||
InventorySlot slot = PlayerInventory.Instance.GetSlot(slotIndex);
|
||||
if (slot == null || slot.IsEmpty) return;
|
||||
if (!hasItem) return;
|
||||
|
||||
draggedSlot = this;
|
||||
|
||||
// Show ghost
|
||||
if (ghostObject != null)
|
||||
{
|
||||
ghostObject.SetActive(true);
|
||||
ghostIcon.sprite = slot.ItemData.Icon;
|
||||
ghostIcon.sprite = iconImage.sprite;
|
||||
ghostIcon.gameObject.SetActive(true);
|
||||
|
||||
bool showQty = slot.Quantity > 1;
|
||||
bool showQty = quantityText.gameObject.activeSelf;
|
||||
ghostQuantity.gameObject.SetActive(showQty);
|
||||
if (showQty)
|
||||
ghostQuantity.text = slot.Quantity.ToString();
|
||||
if (showQty) ghostQuantity.text = quantityText.text;
|
||||
|
||||
ghostObject.transform.position = eventData.position;
|
||||
}
|
||||
|
||||
// Make source icon semi-transparent
|
||||
Color c = iconImage.color;
|
||||
c.a = 0.4f;
|
||||
iconImage.color = c;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the ghost with the pointer.
|
||||
/// </summary>
|
||||
public void OnDrag(PointerEventData eventData)
|
||||
{
|
||||
if (draggedSlot != this) return;
|
||||
|
||||
if (ghostObject != null)
|
||||
ghostObject.transform.position = eventData.position;
|
||||
if (ghostObject != null) ghostObject.transform.position = eventData.position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the drag: hides the ghost and restores the source cell's opacity.
|
||||
/// </summary>
|
||||
public void OnEndDrag(PointerEventData eventData)
|
||||
{
|
||||
if (draggedSlot != this) return;
|
||||
|
||||
// Hide ghost
|
||||
if (ghostObject != null)
|
||||
ghostObject.SetActive(false);
|
||||
if (ghostObject != null) ghostObject.SetActive(false);
|
||||
|
||||
// Restore icon opacity
|
||||
Color c = iconImage.color;
|
||||
c.a = 1f;
|
||||
iconImage.color = c;
|
||||
@@ -161,37 +239,46 @@ namespace Ashwild.Inventory
|
||||
draggedSlot = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop target: reports the dragged and target (container, index) so the manager routes the
|
||||
/// transfer (swap, deposit, withdraw or move).
|
||||
/// </summary>
|
||||
public void OnDrop(PointerEventData eventData)
|
||||
{
|
||||
if (draggedSlot == null || draggedSlot == this) return;
|
||||
|
||||
onSwapRequested?.Invoke(draggedSlot.SlotIndex, slotIndex);
|
||||
onDrop?.Invoke(draggedSlot.container, draggedSlot.slotIndex, container, slotIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports a right-click so the manager can act (context menu in normal mode, quick transfer
|
||||
/// while a chest is open).
|
||||
/// </summary>
|
||||
public void OnPointerClick(PointerEventData eventData)
|
||||
{
|
||||
if (eventData.dragging) return;
|
||||
|
||||
if (eventData.button == PointerEventData.InputButton.Right)
|
||||
onClicked?.Invoke(slotIndex, true);
|
||||
onClicked?.Invoke(container, slotIndex, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports the hovered slot index so the manager can show its description panel. Suppressed
|
||||
/// while a drag is in progress, where the panel would only get in the way.
|
||||
/// Reports the hovered cell so the manager can show its description panel. Suppressed while a
|
||||
/// drag is in progress, where the panel would only get in the way.
|
||||
/// </summary>
|
||||
public void OnPointerEnter(PointerEventData eventData)
|
||||
{
|
||||
if (draggedSlot != null) return;
|
||||
onHoverEnter?.Invoke(slotIndex);
|
||||
onHoverEnter?.Invoke(container, slotIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports that the pointer left the slot so the manager can hide the description panel.
|
||||
/// Reports that the pointer left the cell so the manager can hide the description panel.
|
||||
/// </summary>
|
||||
public void OnPointerExit(PointerEventData eventData)
|
||||
{
|
||||
onHoverExit?.Invoke();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user