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
504 lines
20 KiB
C#
504 lines
20 KiB
C#
using System;
|
|
using FishNet.Connection;
|
|
using FishNet.Object;
|
|
using FishNet.Object.Synchronizing;
|
|
using UnityEngine;
|
|
using Ashwild.Interaction;
|
|
using Ashwild.Inventory;
|
|
using Ashwild.Network;
|
|
using Ashwild.Player;
|
|
|
|
namespace Ashwild.Storage
|
|
{
|
|
/// <summary>
|
|
/// A networked storage chest and the single IInteractable that opens it. Interacting opens the
|
|
/// inventory window in chest mode (the player's inventory on the left, this chest's module on the
|
|
/// right); all item moves are requested through server RPCs.
|
|
///
|
|
/// Multiplayer: the chest is **server-authoritative** so two players browsing the same chest can
|
|
/// never clobber or duplicate items. The server owns the rich model (<see cref="InventorySlot"/>[])
|
|
/// and replicates a compact per-slot view (<see cref="ChestSlotView"/>: item id + quantity + uses)
|
|
/// through a SyncList that every client renders from. The player inventory itself stays
|
|
/// client-authoritative: depositing removes the item locally then asks the server to store it
|
|
/// (refunded if it no longer fits), and withdrawing has the server grant the item back through
|
|
/// <see cref="PlayerInventory.GrantItemFromServer"/> — mirroring the CookingStation pattern.
|
|
/// </summary>
|
|
[DisallowMultipleComponent]
|
|
[RequireComponent(typeof(NetworkObject))]
|
|
public class Chest : NetworkBehaviour, IInteractable
|
|
{
|
|
#region Types
|
|
|
|
/// <summary>
|
|
/// Replicated per-slot view: the stored item's network id (0 = empty), how many, and the
|
|
/// remaining uses of a uses-tracked instance (-1 for items without a uses bar). Clients render
|
|
/// the slot from this; the authoritative model stays server-side.
|
|
/// </summary>
|
|
public struct ChestSlotView
|
|
{
|
|
public ushort rawId;
|
|
public int quantity;
|
|
public int uses;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Serialized Fields
|
|
|
|
[Header("Data")]
|
|
[Tooltip("Authoring data for this chest: slot count, title and grid width.")]
|
|
[SerializeField] private ChestData data;
|
|
|
|
#endregion
|
|
|
|
#region Networked State
|
|
|
|
/// <summary>
|
|
/// One view per storage slot, seeded server-side and replicated to every client. The UI reads
|
|
/// this on both the server and clients so a host and a pure client render identically.
|
|
/// </summary>
|
|
private readonly SyncList<ChestSlotView> slotViews = new SyncList<ChestSlotView>();
|
|
|
|
#endregion
|
|
|
|
#region Server State
|
|
|
|
/// <summary>
|
|
/// Server-only authoritative contents, parallel to <see cref="slotViews"/>. Null on clients,
|
|
/// which never read it — every mutation happens inside a ServerRpc body.
|
|
/// </summary>
|
|
private InventorySlot[] slots;
|
|
|
|
#endregion
|
|
|
|
#region Events
|
|
|
|
/// <summary>
|
|
/// Fired when a slot's replicated view changes so the open <see cref="ChestPanelUI"/> can refresh
|
|
/// just that cell. An index of -1 means the whole collection changed (a full refresh).
|
|
/// </summary>
|
|
public event Action<int> SlotViewChanged;
|
|
|
|
#endregion
|
|
|
|
#region Public API
|
|
|
|
/// <summary>
|
|
/// Number of storage slots this chest exposes, taken from its data (falls back to the
|
|
/// replicated view count if the data is missing).
|
|
/// </summary>
|
|
public int SlotCount => data != null ? data.SlotCount : slotViews.Count;
|
|
|
|
/// <summary>
|
|
/// Title shown at the top of the chest window.
|
|
/// </summary>
|
|
public string DisplayName => data != null ? data.DisplayName : "Coffre";
|
|
|
|
#endregion
|
|
|
|
#region Unity Lifecycle
|
|
|
|
/// <summary>
|
|
/// Warns early when the chest has no data, so a misconfigured prefab is obvious in the console.
|
|
/// </summary>
|
|
private void Awake()
|
|
{
|
|
if (data == null)
|
|
Debug.LogError($"[Chest] '{name}' has no ChestData assigned — the chest will have no slots.", this);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Network Lifecycle
|
|
|
|
/// <summary>
|
|
/// Subscribes to the replicated state so the open window follows it on every machine.
|
|
/// </summary>
|
|
public override void OnStartNetwork()
|
|
{
|
|
base.OnStartNetwork();
|
|
slotViews.OnChange += OnSlotViewChanged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side: builds the authoritative slot model and seeds one empty view per slot so
|
|
/// clients receive a correctly sized collection.
|
|
/// </summary>
|
|
public override void OnStartServer()
|
|
{
|
|
base.OnStartServer();
|
|
|
|
int count = data != null ? data.SlotCount : 0;
|
|
slots = new InventorySlot[count];
|
|
for (int i = 0; i < count; i++) slots[i] = new InventorySlot();
|
|
|
|
slotViews.Clear();
|
|
for (int i = 0; i < count; i++)
|
|
slotViews.Add(new ChestSlotView { rawId = 0, quantity = 0, uses = -1 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unsubscribes — mirrors OnStartNetwork exactly.
|
|
/// </summary>
|
|
public override void OnStopNetwork()
|
|
{
|
|
base.OnStopNetwork();
|
|
slotViews.OnChange -= OnSlotViewChanged;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region IInteractable
|
|
|
|
/// <summary>
|
|
/// Prompt shown while aiming at the chest.
|
|
/// </summary>
|
|
public string InteractionPrompt => $"Ouvrir {DisplayName}";
|
|
|
|
/// <summary>
|
|
/// Opens the inventory window in chest mode, bound to this chest. Runs on the interacting client
|
|
/// only.
|
|
/// </summary>
|
|
public void Interact()
|
|
{
|
|
if (InventoryUI.Instance == null)
|
|
{
|
|
Debug.LogError("[Chest] No InventoryUI found in the scene — cannot open the chest.", this);
|
|
return;
|
|
}
|
|
InventoryUI.Instance.OpenChest(this);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region View Access
|
|
|
|
/// <summary>
|
|
/// Resolves a slot's stored item from the replicated view. Returns false for an empty slot or
|
|
/// an unknown id. Safe on server and clients (both read the replicated view).
|
|
/// </summary>
|
|
public bool TryGetSlot(int index, out ItemData item, out int quantity, out int uses)
|
|
{
|
|
item = null;
|
|
quantity = 0;
|
|
uses = -1;
|
|
|
|
if (index < 0 || index >= slotViews.Count) return false;
|
|
|
|
ChestSlotView v = slotViews[index];
|
|
if (v.rawId == 0) return false;
|
|
|
|
item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(v.rawId) : null;
|
|
if (item == null) return false;
|
|
|
|
quantity = v.quantity;
|
|
uses = v.uses;
|
|
return true;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Client Requests
|
|
|
|
/// <summary>
|
|
/// The one drag-and-drop operation for anything involving this chest: chest→inventory,
|
|
/// inventory→chest and chest→chest, in either direction. It never decides *what* happens — the
|
|
/// shared <see cref="SlotTransfer.Move"/> rules do — so dropping a stack on a chest cell behaves
|
|
/// exactly like dropping it on an inventory cell (move, merge or swap).
|
|
///
|
|
/// For a cross-container move the player's slot is read and cleared locally (the inventory is
|
|
/// client-authoritative) and sent along as a payload; the server reconciles it against the chest
|
|
/// slot and grants whatever comes back into that same slot, which is why an item never lands
|
|
/// somewhere unexpected like the first free hotbar cell.
|
|
/// </summary>
|
|
public void RequestMove(SlotContainer fromContainer, int fromIndex, SlotContainer toContainer, int toIndex)
|
|
{
|
|
if (fromContainer == SlotContainer.Chest && toContainer == SlotContainer.Chest)
|
|
{
|
|
if (fromIndex != toIndex) MoveWithinServerRpc(fromIndex, toIndex);
|
|
return;
|
|
}
|
|
|
|
PlayerInventory inv = PlayerInventory.Instance;
|
|
if (inv == null) return;
|
|
|
|
bool chestIsSource = fromContainer == SlotContainer.Chest;
|
|
int chestIndex = chestIsSource ? fromIndex : toIndex;
|
|
int inventoryIndex = chestIsSource ? toIndex : fromIndex;
|
|
|
|
if (chestIndex < 0 || chestIndex >= slotViews.Count) return;
|
|
if (inv.GetSlot(inventoryIndex) == null) return;
|
|
|
|
SlotContent payload = inv.ReadSlot(inventoryIndex);
|
|
bool chestSlotFilled = TryGetSlot(chestIndex, out _, out _, out _);
|
|
|
|
// Nothing to move: the side the player dragged from is empty.
|
|
if (chestIsSource ? !chestSlotFilled : payload.IsEmpty) return;
|
|
|
|
ushort id = 0;
|
|
if (!payload.IsEmpty)
|
|
{
|
|
id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(payload.Item) : (ushort)0;
|
|
if (id == 0)
|
|
{
|
|
Debug.LogError($"[Chest] '{payload.Item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this);
|
|
return;
|
|
}
|
|
inv.WriteSlot(inventoryIndex, SlotContent.Empty);
|
|
}
|
|
|
|
MoveCrossServerRpc(chestIndex, inventoryIndex, chestIsSource, id, payload.Quantity, payload.Uses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Quick transfer (right-click): sends a stack to the other container without the player picking a
|
|
/// destination, so it is auto-placed into the first slot that accepts it. Deliberately a different
|
|
/// operation from <see cref="RequestMove"/> — here the player expressed no target.
|
|
/// </summary>
|
|
public void RequestQuickTransfer(SlotContainer fromContainer, int index)
|
|
{
|
|
PlayerInventory inv = PlayerInventory.Instance;
|
|
if (inv == null) return;
|
|
|
|
if (fromContainer == SlotContainer.Chest)
|
|
{
|
|
if (!TryGetSlot(index, out ItemData item, out int quantity, out _)) return;
|
|
if (!inv.CanFit(item, quantity))
|
|
{
|
|
Debug.LogWarning($"[Chest] Inventory full — cannot withdraw '{item.ItemName}'.", this);
|
|
PlayerEvents.RaiseInteractionRefused("Inventory full");
|
|
return;
|
|
}
|
|
QuickWithdrawServerRpc(index);
|
|
return;
|
|
}
|
|
|
|
SlotContent content = inv.ReadSlot(index);
|
|
if (content.IsEmpty) return;
|
|
|
|
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(content.Item) : (ushort)0;
|
|
if (id == 0)
|
|
{
|
|
Debug.LogError($"[Chest] '{content.Item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this);
|
|
return;
|
|
}
|
|
|
|
inv.WriteSlot(index, SlotContent.Empty);
|
|
QuickDepositServerRpc(id, content.Quantity, content.Uses, index);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores every inventory stack in the chest, auto-placing each. Safe: the server refunds whatever
|
|
/// does not fit back to the slot it came from.
|
|
/// </summary>
|
|
public void RequestDepositAll()
|
|
{
|
|
PlayerInventory inv = PlayerInventory.Instance;
|
|
if (inv == null) return;
|
|
|
|
for (int i = 0; i < inv.InventorySize; i++)
|
|
RequestQuickTransfer(SlotContainer.Inventory, i);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes every chest stack into the inventory. Each slot is CanFit-checked as it goes; because the
|
|
/// grants come back asynchronously the checks do not account for items still in flight, so a chest
|
|
/// larger than the free inventory space may leave some items behind.
|
|
/// </summary>
|
|
public void RequestWithdrawAll()
|
|
{
|
|
for (int i = 0; i < slotViews.Count; i++)
|
|
RequestQuickTransfer(SlotContainer.Chest, i);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Server RPCs
|
|
|
|
/// <summary>
|
|
/// Server-side chest-to-chest move: both slots are ours, so the shared rules run straight on the
|
|
/// authoritative model.
|
|
/// </summary>
|
|
[ServerRpc(RequireOwnership = false)]
|
|
private void MoveWithinServerRpc(int fromIndex, int toIndex)
|
|
{
|
|
if (fromIndex < 0 || fromIndex >= slots.Length) return;
|
|
if (toIndex < 0 || toIndex >= slots.Length) return;
|
|
|
|
SlotContent from = ReadServerSlot(fromIndex);
|
|
SlotContent to = ReadServerSlot(toIndex);
|
|
|
|
if (!SlotTransfer.Move(ref from, ref to)) return;
|
|
|
|
WriteServerSlot(fromIndex, from);
|
|
WriteServerSlot(toIndex, to);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side inventory↔chest move, both directions. The player's slot arrives as a payload (empty
|
|
/// when they dragged onto an empty cell); the same rules that drive an inventory-to-inventory drag
|
|
/// decide between move, merge and swap, then whatever ends up on the player's side is granted back
|
|
/// into the exact slot they used. An out-of-range chest index refunds the payload rather than
|
|
/// swallowing it.
|
|
/// </summary>
|
|
[ServerRpc(RequireOwnership = false)]
|
|
private void MoveCrossServerRpc(int chestIndex, int inventoryIndex, bool chestIsSource, ushort id, int quantity, int uses, NetworkConnection conn = null)
|
|
{
|
|
ItemData payloadItem = id != 0 && ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null;
|
|
SlotContent playerContent = SlotContent.Of(payloadItem, quantity, uses);
|
|
|
|
if (chestIndex < 0 || chestIndex >= slots.Length)
|
|
{
|
|
GrantBack(conn, playerContent, inventoryIndex);
|
|
return;
|
|
}
|
|
|
|
SlotContent chestContent = ReadServerSlot(chestIndex);
|
|
|
|
if (chestIsSource) SlotTransfer.Move(ref chestContent, ref playerContent);
|
|
else SlotTransfer.Move(ref playerContent, ref chestContent);
|
|
|
|
WriteServerSlot(chestIndex, chestContent);
|
|
GrantBack(conn, playerContent, inventoryIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side quick deposit: auto-places the incoming stack and refunds any leftover to the slot
|
|
/// it was taken from.
|
|
/// </summary>
|
|
[ServerRpc(RequireOwnership = false)]
|
|
private void QuickDepositServerRpc(ushort id, int quantity, int uses, int originIndex, NetworkConnection conn = null)
|
|
{
|
|
ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null;
|
|
SlotContent incoming = SlotContent.Of(item, quantity, uses);
|
|
if (incoming.IsEmpty) return;
|
|
|
|
AutoPlace(ref incoming);
|
|
GrantBack(conn, incoming, originIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side quick withdraw: empties the chest slot and lets the player's inventory auto-place it.
|
|
/// </summary>
|
|
[ServerRpc(RequireOwnership = false)]
|
|
private void QuickWithdrawServerRpc(int chestIndex, NetworkConnection conn = null)
|
|
{
|
|
if (chestIndex < 0 || chestIndex >= slots.Length) return;
|
|
|
|
SlotContent content = ReadServerSlot(chestIndex);
|
|
if (content.IsEmpty) return;
|
|
|
|
WriteServerSlot(chestIndex, SlotContent.Empty);
|
|
GrantBack(conn, content, -1);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Server Helpers
|
|
|
|
/// <summary>
|
|
/// Auto-places a stack into the chest through the shared rule: merge into matching stacks first,
|
|
/// then fill empty slots. Never swaps — the player picked no destination. Whatever does not fit is
|
|
/// left in <paramref name="incoming"/> for the caller to refund.
|
|
/// </summary>
|
|
private void AutoPlace(ref SlotContent incoming)
|
|
{
|
|
for (int i = 0; i < slots.Length && !incoming.IsEmpty; i++)
|
|
if (!slots[i].IsEmpty) StackIntoSlot(i, ref incoming);
|
|
|
|
for (int i = 0; i < slots.Length && !incoming.IsEmpty; i++)
|
|
if (slots[i].IsEmpty) StackIntoSlot(i, ref incoming);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes as much of the incoming stack as one chest slot accepts, writing it back when it changed.
|
|
/// </summary>
|
|
private void StackIntoSlot(int index, ref SlotContent incoming)
|
|
{
|
|
SlotContent target = ReadServerSlot(index);
|
|
if (!SlotTransfer.TryStack(ref incoming, ref target)) return;
|
|
WriteServerSlot(index, target);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a chest slot from the authoritative model as a container-agnostic snapshot.
|
|
/// </summary>
|
|
private SlotContent ReadServerSlot(int index)
|
|
{
|
|
InventorySlot s = slots[index];
|
|
if (s.IsEmpty) return SlotContent.Empty;
|
|
return SlotContent.Of(s.ItemData, s.Quantity, s.CurrentUses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes a snapshot into the authoritative model and pushes it into the replicated view in one
|
|
/// step, so the two can never drift apart. Carries the remaining uses, so a worn tool stored in a
|
|
/// chest comes back out just as worn.
|
|
/// </summary>
|
|
private void WriteServerSlot(int index, SlotContent content)
|
|
{
|
|
InventorySlot s = slots[index];
|
|
|
|
if (content.IsEmpty)
|
|
{
|
|
s.Clear();
|
|
slotViews[index] = new ChestSlotView { rawId = 0, quantity = 0, uses = -1 };
|
|
return;
|
|
}
|
|
|
|
s.Set(content.Item, content.Quantity, content.Uses);
|
|
|
|
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(content.Item) : (ushort)0;
|
|
slotViews[index] = new ChestSlotView { rawId = id, quantity = content.Quantity, uses = content.Uses };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grants a stack to the requesting player's (client-authoritative) inventory. No-op when empty.
|
|
/// <paramref name="preferredIndex"/> is the slot the player used, so a refund or a swapped-out
|
|
/// stack returns exactly there instead of auto-filling the first free slot (the hotbar). Negative
|
|
/// means auto-place. Always flagged as a transfer: taking a stack out of a chest is shuffling
|
|
/// items between the player's own containers, not a gain, so the HUD must not announce it.
|
|
/// </summary>
|
|
private void GrantBack(NetworkConnection conn, SlotContent content, int preferredIndex)
|
|
{
|
|
if (content.IsEmpty) return;
|
|
PlayerInventory inv = ResolveInventory(conn);
|
|
if (inv != null) inv.GrantItemFromServer(content.Item, content.Quantity, content.Uses, preferredIndex, isTransfer: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the PlayerInventory owned by the given connection. Delegates to the shared lookup,
|
|
/// which scans the connection's objects instead of trusting FirstObject (see NetworkPlayerLookup).
|
|
/// </summary>
|
|
private PlayerInventory ResolveInventory(NetworkConnection conn) => NetworkPlayerLookup.ResolveInventory(conn);
|
|
|
|
#endregion
|
|
|
|
#region Replication Handlers
|
|
|
|
/// <summary>
|
|
/// Forwards a replicated slot change to the open window: a single index for point edits, or -1
|
|
/// when the whole collection is (re)seeded on join.
|
|
/// </summary>
|
|
private void OnSlotViewChanged(SyncListOperation op, int index, ChestSlotView oldItem, ChestSlotView newItem, bool asServer)
|
|
{
|
|
switch (op)
|
|
{
|
|
case SyncListOperation.Add:
|
|
case SyncListOperation.Insert:
|
|
case SyncListOperation.Set:
|
|
case SyncListOperation.RemoveAt:
|
|
SlotViewChanged?.Invoke(index);
|
|
break;
|
|
|
|
case SyncListOperation.Clear:
|
|
case SyncListOperation.Complete:
|
|
SlotViewChanged?.Invoke(-1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|