78bfdf2828
# Conflicts: # Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat # Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta # Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta # Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
414 lines
15 KiB
C#
414 lines
15 KiB
C#
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
using TMPro;
|
|
using Ashwild.Player;
|
|
using Ashwild.Storage;
|
|
using Ashwild.UI;
|
|
|
|
namespace Ashwild.Inventory
|
|
{
|
|
/// <summary>
|
|
/// 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). 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;
|
|
[SerializeField] private GameObject slotPrefab;
|
|
|
|
[Header("Drag Ghost")]
|
|
[SerializeField] private GameObject ghostObject;
|
|
[SerializeField] private Image ghostIcon;
|
|
[SerializeField] private TextMeshProUGUI ghostQuantityText;
|
|
|
|
[Header("Context Menu")]
|
|
[SerializeField] private SlotContextMenu contextMenu;
|
|
|
|
[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;
|
|
|
|
[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;
|
|
|
|
/// <summary>
|
|
/// The tab the next open must land on, or null for the default. Set by whoever opens the window
|
|
/// on a specific module (a crafting station wanting the craft tab) and consumed by Show(), so it
|
|
/// applies to exactly one opening and never leaks into the next.
|
|
/// </summary>
|
|
private InventoryCategory? pendingCategory;
|
|
|
|
#endregion
|
|
|
|
#region Unity Lifecycle
|
|
|
|
/// <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>
|
|
private void OnEnable()
|
|
{
|
|
PlayerEvents.LocalPlayerSpawned += HandleLocalPlayerSpawned;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unsubscribes — mirrors OnEnable exactly.
|
|
/// </summary>
|
|
private void OnDisable()
|
|
{
|
|
PlayerEvents.LocalPlayerSpawned -= HandleLocalPlayerSpawned;
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
// Setup shared ghost for all SlotUIs (hotbar + inventory + chest) — does not need the player.
|
|
SlotUI.SetupGhost(ghostObject, ghostIcon, ghostQuantityText);
|
|
inventoryPanel.SetActive(false);
|
|
|
|
// The player may already exist (late UI init); otherwise we wait for the spawn event.
|
|
if (PlayerInventory.Instance != null)
|
|
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>
|
|
private void HandleLocalPlayerSpawned() => BuildInventory();
|
|
|
|
/// <summary>
|
|
/// Creates the non-hotbar slot UIs from the local player's inventory; runs once.
|
|
/// </summary>
|
|
private void BuildInventory()
|
|
{
|
|
if (bound) return;
|
|
inventory = PlayerInventory.Instance;
|
|
if (inventory == null) return;
|
|
bound = true;
|
|
|
|
// Only create slots for non-hotbar indices (hotbarSize to inventorySize-1)
|
|
int nonHotbarCount = inventory.InventorySize - inventory.HotbarSize;
|
|
slotUIs = new SlotUI[nonHotbarCount];
|
|
for (int i = 0; i < nonHotbarCount; i++)
|
|
{
|
|
int slotIndex = inventory.HotbarSize + i;
|
|
GameObject slotGO = Instantiate(slotPrefab, slotContainer);
|
|
SlotUI slotUI = slotGO.GetComponent<SlotUI>();
|
|
slotUI.Initialize(SlotContainer.Inventory, slotIndex,
|
|
HandleSlotDrop, OnSlotClicked, OnSlotHoverEnter, OnSlotHoverExit);
|
|
slotUIs[i] = slotUI;
|
|
}
|
|
|
|
inventory.onSlotChanged.AddListener(RefreshSlot);
|
|
RefreshAll();
|
|
}
|
|
|
|
#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 (chest == null) return;
|
|
boundChest = chest;
|
|
|
|
if (UIManager.Instance != null)
|
|
UIManager.Instance.OpenPanel(this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens the window on a specific tab — used by a crafting station, which wants the craft panel
|
|
/// straight away rather than making the player click across. The tab is picked up in Show().
|
|
/// </summary>
|
|
public void OpenAtCategory(InventoryCategory category)
|
|
{
|
|
pendingCategory = category;
|
|
|
|
if (UIManager.Instance != null)
|
|
UIManager.Instance.OpenPanel(this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens the inventory window. Enters chest mode when a chest was bound (right side shows the
|
|
/// chest module, category is forced to and locked on the inventory), otherwise normal browsing
|
|
/// (right side shows the description). Lands on the tab a caller requested, or on the default
|
|
/// (inventory) otherwise — a plain open never stays on craft from a previous session, and chest
|
|
/// mode always wins since it needs the grid.
|
|
/// </summary>
|
|
public override void Show()
|
|
{
|
|
inventoryPanel.SetActive(true);
|
|
bool chestMode = boundChest != null;
|
|
|
|
if (categoryManager != null)
|
|
{
|
|
if (!chestMode && pendingCategory.HasValue) categoryManager.Open(pendingCategory.Value);
|
|
else categoryManager.Open();
|
|
|
|
categoryManager.SetLocked(chestMode);
|
|
}
|
|
|
|
pendingCategory = null;
|
|
|
|
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 window and tears down its transient UI (ghost, context menu, chest binding).
|
|
/// </summary>
|
|
public override void Hide()
|
|
{
|
|
if (ghostObject != null)
|
|
ghostObject.SetActive(false);
|
|
|
|
if (contextMenu != null)
|
|
contextMenu.Hide();
|
|
|
|
if (hoverDescription != null)
|
|
hoverDescription.Hide();
|
|
|
|
if (categoryManager != null)
|
|
{
|
|
categoryManager.SetLocked(false);
|
|
categoryManager.Close();
|
|
}
|
|
|
|
if (hotbarUI != null)
|
|
hotbarUI.OnInventoryClose();
|
|
|
|
CloseChestMode();
|
|
|
|
inventoryPanel.SetActive(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Instant hide used when the manager initializes panels — just parks the window closed.
|
|
/// </summary>
|
|
public override void HideInstant()
|
|
{
|
|
if (ghostObject != null)
|
|
ghostObject.SetActive(false);
|
|
|
|
CloseChestMode();
|
|
|
|
inventoryPanel.SetActive(false);
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
if (boundChest == null) return;
|
|
|
|
if (chestPanel != null) chestPanel.Hide();
|
|
boundChest = null;
|
|
if (descriptionModule != null) descriptionModule.SetActive(true);
|
|
PlayerEvents.RaiseChestOpenChanged(false);
|
|
}
|
|
|
|
#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 (fromContainer == SlotContainer.Inventory && toContainer == SlotContainer.Inventory)
|
|
{
|
|
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>
|
|
/// 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>
|
|
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);
|
|
if (slot == null || slot.IsEmpty)
|
|
{
|
|
hoverDescription.Hide();
|
|
return;
|
|
}
|
|
|
|
hoverDescription.Show(ItemDescriptionView.From(slot));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hides the description panel when the pointer leaves a slot.
|
|
/// </summary>
|
|
private void OnSlotHoverExit()
|
|
{
|
|
if (hoverDescription != null)
|
|
hoverDescription.Hide();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Refresh
|
|
|
|
private void RefreshSlot(int index)
|
|
{
|
|
if (slotUIs == null) return;
|
|
|
|
int localIndex = index - inventory.HotbarSize;
|
|
if (localIndex >= 0 && localIndex < slotUIs.Length)
|
|
slotUIs[localIndex].UpdateVisual(inventory.GetSlot(index));
|
|
}
|
|
|
|
private void RefreshAll()
|
|
{
|
|
if (slotUIs == null) return;
|
|
|
|
for (int i = 0; i < slotUIs.Length; i++)
|
|
{
|
|
int slotIndex = inventory.HotbarSize + i;
|
|
slotUIs[i].UpdateVisual(inventory.GetSlot(slotIndex));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|