Files
Emberwild/Assets/GAME/Script/Inventory/InventoryUI.cs
T
2026-07-22 12:56:13 +02:00

389 lines
14 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;
#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 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);
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 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
}
}