Files
Emberwild/Assets/GAME/Script/Inventory/HotbarController.cs
T
Mathew 78bfdf2828 Merge remote-tracking branch 'origin/feat/inport-props' into feat/craft-discovery
# Conflicts:
#	Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
2026-07-25 19:42:26 +02:00

228 lines
8.7 KiB
C#

using UnityEngine;
using Ashwild.Player;
namespace Ashwild.Inventory
{
/// <summary>
/// Keeps the item in the player's hand in sync with the selected hotbar slot. It owns the *timing*
/// of a swap, not its look: the put-away and draw animations live in the arms rig, and this
/// controller simply waits for them.
///
/// The wait is what makes the swap read correctly. Destroying the old prefab the instant the
/// selection changes makes the item vanish mid-motion, so instead the sequence is: ask for the
/// put-away animation, wait for its Animation Event, then destroy, announce the new item (which
/// lets the animation binder load its clips first), spawn it, and ask for the draw animation.
///
/// That wait is guarded by a timeout, because the completion event only exists if someone plays the
/// clip. An item whose set has no put-away clip, a rig that was never wired, a disabled arms
/// object — any of those would otherwise leave the player stuck holding an item he already
/// switched away from, with no error to explain it. The timeout turns a silent deadlock into a
/// slightly abrupt swap.
/// </summary>
[DisallowMultipleComponent]
public class HotbarController : MonoBehaviour
{
#region Serialized Fields
[Header("References")]
[Tooltip("Attachment joint in the arms rig's right hand (RightHand_Holder_JTN). Held prefabs are parented here so they follow the animated hand.")]
[SerializeField] private Transform handHolder;
[Tooltip("Origin and forward of the aim ray handed to held items — normally the first-person camera.")]
[SerializeField] private Transform raycastOrigin;
[Header("Swap")]
[Tooltip("Seconds to wait for the put-away animation before swapping anyway. Safety net only — a wired rig always completes first.")]
[SerializeField] private float stowTimeout = 0.5f;
#endregion
#region State
private PlayerInventory inventory;
private HeldItemContext heldContext;
private GameObject currentHeldObject;
private ItemData currentHeldItem;
private bool isStowing;
private ItemData pendingItem;
private float stowDeadline;
private bool stowTimeoutReported;
#endregion
#region Unity Lifecycle
/// <summary>
/// Binds to the local inventory and draws whatever is already selected, without animation —
/// spawning into the world should not look like the player just swapped weapons.
/// </summary>
private void Start()
{
inventory = PlayerInventory.Instance;
if (inventory == null)
{
Debug.LogError("[HotbarController] No local PlayerInventory — the hand will stay empty.", this);
return;
}
inventory.onSelectedSlotChanged.AddListener(HandleSelectionChanged);
inventory.onSlotChanged.AddListener(HandleSlotChanged);
heldContext = new HeldItemContext { RaycastOrigin = raycastOrigin };
UpdateHeldItem(false);
}
/// <summary>
/// Listens for the put-away animation finishing. Paired with OnDisable.
/// </summary>
private void OnEnable()
{
PlayerEvents.UnequipAnimComplete += HandleUnequipAnimComplete;
}
/// <summary>
/// Unsubscribes — mirrors OnEnable exactly.
/// </summary>
private void OnDisable()
{
PlayerEvents.UnequipAnimComplete -= HandleUnequipAnimComplete;
}
/// <summary>
/// Drops the inventory listeners; the bus subscription is already handled by OnDisable.
/// </summary>
private void OnDestroy()
{
if (inventory == null) return;
inventory.onSelectedSlotChanged.RemoveListener(HandleSelectionChanged);
inventory.onSlotChanged.RemoveListener(HandleSlotChanged);
}
/// <summary>
/// Forces the swap through when the put-away animation never reports back. Runs only while a
/// swap is actually pending, so an idle player costs nothing. The diagnostic is logged once per
/// session rather than per swap: the cause is always a wiring or authoring gap that will repeat
/// on every single hotbar change, and a console flooded with the same warning hides the rest.
/// </summary>
private void Update()
{
if (!isStowing) return;
if (Time.time < stowDeadline) return;
if (!stowTimeoutReported)
{
stowTimeoutReported = true;
Debug.LogWarning("[HotbarController] The put-away animation never completed — swapping anyway " +
"(logged once). Check that the arms rig has an ArmsAnimationEvents component and " +
"that the Unequip clip carries an AnimUnequipComplete event. Until those clips " +
"exist, lower Stow Timeout so the swap stays snappy.", this);
}
CommitSwap();
}
#endregion
#region Event Handlers
private void HandleSelectionChanged(int index) => UpdateHeldItem(true);
/// <summary>
/// The held item's own stack changed (it was consumed, worn out, or refilled) — redraw only when
/// it is the selected slot.
/// </summary>
private void HandleSlotChanged(int index)
{
if (index == inventory.SelectedHotbarIndex) UpdateHeldItem(true);
}
/// <summary>
/// The arms finished putting the old item away, so the swap can complete.
/// </summary>
private void HandleUnequipAnimComplete()
{
if (isStowing) CommitSwap();
}
#endregion
#region Swap Sequence
/// <summary>
/// Entry point for every reason the hand might need to change. Swaps straight away when there is
/// nothing to put away (empty hands, or the very first draw), otherwise starts the put-away
/// animation and defers. A selection that changes again mid-stow only updates the target: the
/// player who scrolls three slots quickly plays one put-away, not three.
/// </summary>
private void UpdateHeldItem(bool animate)
{
InventorySlot selected = inventory.GetSelectedSlot();
ItemData newItem = selected.IsEmpty ? null : selected.ItemData;
if (newItem == currentHeldItem && !isStowing) return;
pendingItem = newItem;
if (isStowing) return;
if (!animate || currentHeldObject == null)
{
CommitSwap();
return;
}
isStowing = true;
stowDeadline = Time.time + stowTimeout;
PlayerEvents.RaiseUnequipStarted();
}
/// <summary>
/// Performs the actual exchange. HeldItemChanged is raised *before* the new prefab is spawned so
/// the animation binder has already loaded the item's clips by the time the draw trigger fires —
/// otherwise the first frame of the draw would play the previous item's animation.
/// </summary>
private void CommitSwap()
{
isStowing = false;
if (currentHeldObject != null)
{
Destroy(currentHeldObject);
currentHeldObject = null;
}
currentHeldItem = pendingItem;
PlayerEvents.RaiseHeldItemChanged(currentHeldItem);
SpawnItem(currentHeldItem);
if (currentHeldItem != null) PlayerEvents.RaiseEquipStarted();
}
/// <summary>
/// Instantiates the item's hand prefab on the rig's hand joint and links its behaviour to the
/// player. Parenting to the joint rather than to a free-floating holder is what keeps a tool
/// locked in the palm through every animation, with no code following the hand each frame.
/// </summary>
private void SpawnItem(ItemData item)
{
if (item == null || item.HandPrefab == null) return;
if (handHolder == null)
{
Debug.LogError($"[HotbarController] No hand holder assigned — '{item.ItemName}' cannot be drawn. " +
"Wire the arms rig's RightHand_Holder_JTN.", this);
return;
}
currentHeldObject = Instantiate(item.HandPrefab, handHolder);
IHeldItemBehaviour behaviour = currentHeldObject.GetComponentInChildren<IHeldItemBehaviour>(true);
behaviour?.Setup(heldContext, item);
}
#endregion
}
}