(Feat) Add Build

This commit is contained in:
2026-07-22 12:56:13 +02:00
parent 3657b870d8
commit 0052b0d98e
244 changed files with 35752 additions and 3112 deletions
+25 -17
View File
@@ -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;
+188 -54
View File
@@ -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);
}
}
}
+138 -76
View File
@@ -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
+143 -56
View File
@@ -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
}
}