534 lines
20 KiB
C#
534 lines
20 KiB
C#
using System;
|
|
using FishNet.Connection;
|
|
using FishNet.Object;
|
|
using FishNet.Object.Synchronizing;
|
|
using UnityEngine;
|
|
using Ashwild.Interaction;
|
|
using Ashwild.Inventory;
|
|
|
|
namespace Ashwild.Storage
|
|
{
|
|
/// <summary>
|
|
/// A networked storage chest and the single IInteractable that opens it. Interacting opens the
|
|
/// shared <see cref="ChestUI"/> (the player's inventory on the left, this chest 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="ChestUI"/> 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 shared chest window bound to this chest. Runs on the interacting client only.
|
|
/// </summary>
|
|
public void Interact()
|
|
{
|
|
if (ChestUI.Instance == null)
|
|
{
|
|
Debug.LogError("[Chest] No ChestUI found in the scene — cannot open the chest.", this);
|
|
return;
|
|
}
|
|
ChestUI.Instance.Open(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>
|
|
/// Deposits the whole stack in the given inventory slot into a specific chest slot. When the
|
|
/// target chest slot holds a different item this becomes a swap, so it first checks the
|
|
/// displaced stack still fits in the inventory (server refunds anything the target can't hold).
|
|
/// Pass a negative chest slot to let the server auto-place into the first fitting slot.
|
|
/// </summary>
|
|
public void RequestDepositToSlot(int inventoryIndex, int chestSlot)
|
|
{
|
|
PlayerInventory inv = PlayerInventory.Instance;
|
|
if (inv == null) return;
|
|
|
|
InventorySlot slot = inv.GetSlot(inventoryIndex);
|
|
if (slot == null || slot.IsEmpty) return;
|
|
|
|
ItemData item = slot.ItemData;
|
|
int quantity = slot.Quantity;
|
|
int uses = item.HasUses ? slot.CurrentUses : -1;
|
|
|
|
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0;
|
|
if (id == 0)
|
|
{
|
|
Debug.LogError($"[Chest] '{item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this);
|
|
return;
|
|
}
|
|
|
|
if (chestSlot >= 0 && chestSlot < slotViews.Count)
|
|
{
|
|
ChestSlotView target = slotViews[chestSlot];
|
|
if (target.rawId != 0)
|
|
{
|
|
ItemData targetItem = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(target.rawId) : null;
|
|
bool sameStackable = targetItem == item && item.IsStackable && !item.HasUses;
|
|
if (!sameStackable && targetItem != null && !inv.CanFit(targetItem, target.quantity))
|
|
{
|
|
Debug.LogWarning($"[Chest] Inventaire plein — impossible d'échanger avec '{targetItem.ItemName}'.", this);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
inv.RemoveItem(inventoryIndex, quantity);
|
|
RequestDepositServerRpc(chestSlot, id, quantity, uses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deposits every non-empty inventory slot into the chest, auto-placing each. Safe: the server
|
|
/// refunds anything that does not fit back to the same player.
|
|
/// </summary>
|
|
public void RequestDepositAll()
|
|
{
|
|
PlayerInventory inv = PlayerInventory.Instance;
|
|
if (inv == null) return;
|
|
|
|
for (int i = 0; i < inv.InventorySize; i++)
|
|
{
|
|
InventorySlot slot = inv.GetSlot(i);
|
|
if (slot == null || slot.IsEmpty) continue;
|
|
RequestDepositToSlot(i, -1);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Withdraws a chest slot's whole stack into the inventory, after a local CanFit pre-check so a
|
|
/// full inventory leaves the items in the chest instead of losing them.
|
|
/// </summary>
|
|
public void RequestWithdraw(int chestSlot)
|
|
{
|
|
PlayerInventory inv = PlayerInventory.Instance;
|
|
if (inv == null) return;
|
|
|
|
if (!TryGetSlot(chestSlot, out ItemData item, out int quantity, out _)) return;
|
|
if (!inv.CanFit(item, quantity))
|
|
{
|
|
Debug.LogWarning($"[Chest] Inventaire plein — impossible de retirer '{item.ItemName}'.", this);
|
|
return;
|
|
}
|
|
|
|
RequestWithdrawServerRpc(chestSlot, quantity);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Withdraws every non-empty chest slot into the inventory. Each slot is CanFit-checked as it
|
|
/// goes. Because the grants come back asynchronously, the checks do not yet 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++)
|
|
if (slotViews[i].rawId != 0)
|
|
RequestWithdraw(i);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves/merges one chest slot into another (a drag within the chest grid).
|
|
/// </summary>
|
|
public void RequestMoveWithin(int fromSlot, int toSlot)
|
|
{
|
|
RequestMoveWithinServerRpc(fromSlot, toSlot);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Server RPCs
|
|
|
|
/// <summary>
|
|
/// Server-side deposit. Places the incoming stack in the requested slot — filling an empty
|
|
/// slot, merging into a matching stack, or swapping with a different item (the displaced stack
|
|
/// is granted back to the player) — and refunds any leftover. A negative slot auto-places.
|
|
/// </summary>
|
|
[ServerRpc(RequireOwnership = false)]
|
|
private void RequestDepositServerRpc(int chestSlot, ushort id, int quantity, int uses, NetworkConnection conn = null)
|
|
{
|
|
ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null;
|
|
if (item == null || quantity <= 0) return;
|
|
|
|
if (chestSlot < 0 || chestSlot >= slots.Length)
|
|
{
|
|
ServerDepositAuto(item, quantity, uses, conn);
|
|
return;
|
|
}
|
|
|
|
InventorySlot target = slots[chestSlot];
|
|
|
|
if (target.IsEmpty)
|
|
{
|
|
int place = PlaceableCount(item, quantity);
|
|
target.Set(item, place, uses);
|
|
WriteView(chestSlot);
|
|
GrantBack(conn, item, quantity - place, uses);
|
|
}
|
|
else if (!item.HasUses && target.ItemData == item && target.CanAccept(item))
|
|
{
|
|
int leftover = target.AddQuantity(quantity);
|
|
WriteView(chestSlot);
|
|
GrantBack(conn, item, leftover, uses);
|
|
}
|
|
else
|
|
{
|
|
ItemData displaced = target.ItemData;
|
|
int displacedQty = target.Quantity;
|
|
int displacedUses = displaced != null && displaced.HasUses ? target.CurrentUses : -1;
|
|
|
|
int place = PlaceableCount(item, quantity);
|
|
target.Set(item, place, uses);
|
|
WriteView(chestSlot);
|
|
|
|
GrantBack(conn, item, quantity - place, uses);
|
|
GrantBack(conn, displaced, displacedQty, displacedUses);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side withdraw. Removes the whole stack from the chest slot and grants it to the
|
|
/// requesting player.
|
|
/// </summary>
|
|
[ServerRpc(RequireOwnership = false)]
|
|
private void RequestWithdrawServerRpc(int chestSlot, int quantity, NetworkConnection conn = null)
|
|
{
|
|
if (chestSlot < 0 || chestSlot >= slots.Length) return;
|
|
|
|
InventorySlot s = slots[chestSlot];
|
|
if (s.IsEmpty || quantity <= 0) return;
|
|
|
|
ItemData item = s.ItemData;
|
|
int take = Mathf.Min(quantity, s.Quantity);
|
|
int uses = item.HasUses ? s.CurrentUses : -1;
|
|
|
|
s.RemoveQuantity(take);
|
|
WriteView(chestSlot);
|
|
|
|
GrantBack(conn, item, take, uses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side move within the chest: merges into a matching stack, otherwise swaps the two
|
|
/// slots (uses preserved). Never touches any inventory.
|
|
/// </summary>
|
|
[ServerRpc(RequireOwnership = false)]
|
|
private void RequestMoveWithinServerRpc(int fromSlot, int toSlot, NetworkConnection conn = null)
|
|
{
|
|
if (fromSlot < 0 || fromSlot >= slots.Length) return;
|
|
if (toSlot < 0 || toSlot >= slots.Length) return;
|
|
if (fromSlot == toSlot) return;
|
|
|
|
InventorySlot a = slots[fromSlot];
|
|
if (a.IsEmpty) return;
|
|
InventorySlot b = slots[toSlot];
|
|
|
|
if (!b.IsEmpty && !a.ItemData.HasUses && b.ItemData == a.ItemData && b.CanAccept(a.ItemData))
|
|
{
|
|
int leftover = b.AddQuantity(a.Quantity);
|
|
if (leftover <= 0) a.Clear();
|
|
else a.Set(a.ItemData, leftover);
|
|
}
|
|
else
|
|
{
|
|
ItemData ai = a.ItemData;
|
|
int aq = a.Quantity;
|
|
int au = ai != null && ai.HasUses ? a.CurrentUses : -1;
|
|
ItemData bi = b.ItemData;
|
|
int bq = b.Quantity;
|
|
int bu = bi != null && bi.HasUses ? b.CurrentUses : -1;
|
|
|
|
if (bi == null) a.Clear(); else a.Set(bi, bq, bu);
|
|
if (ai == null) b.Clear(); else b.Set(ai, aq, au);
|
|
}
|
|
|
|
WriteView(fromSlot);
|
|
WriteView(toSlot);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Server Helpers
|
|
|
|
/// <summary>
|
|
/// Auto-places an incoming stack: merges into matching non-full slots first, then fills empty
|
|
/// slots (one instance per slot for uses-tracked items), and refunds any leftover.
|
|
/// </summary>
|
|
private void ServerDepositAuto(ItemData item, int quantity, int uses, NetworkConnection conn)
|
|
{
|
|
int remaining = quantity;
|
|
|
|
if (!item.HasUses)
|
|
{
|
|
for (int i = 0; i < slots.Length && remaining > 0; i++)
|
|
{
|
|
if (!slots[i].IsEmpty && slots[i].ItemData == item && slots[i].CanAccept(item))
|
|
{
|
|
remaining = slots[i].AddQuantity(remaining);
|
|
WriteView(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < slots.Length && remaining > 0; i++)
|
|
{
|
|
if (!slots[i].IsEmpty) continue;
|
|
|
|
if (item.HasUses)
|
|
{
|
|
slots[i].Set(item, 1, uses);
|
|
remaining -= 1;
|
|
}
|
|
else
|
|
{
|
|
int place = item.IsStackable ? Mathf.Min(remaining, item.MaxStackSize) : 1;
|
|
slots[i].Set(item, place);
|
|
remaining -= place;
|
|
}
|
|
WriteView(i);
|
|
}
|
|
|
|
GrantBack(conn, item, remaining, uses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// How many units of an item can sit in a single fresh slot: one for uses-tracked or
|
|
/// non-stackable items, otherwise up to the max stack size.
|
|
/// </summary>
|
|
private int PlaceableCount(ItemData item, int quantity)
|
|
{
|
|
int cap = item.HasUses ? 1 : (item.IsStackable ? item.MaxStackSize : 1);
|
|
return Mathf.Min(quantity, cap);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes the server model of a slot into its replicated view.
|
|
/// </summary>
|
|
private void WriteView(int index)
|
|
{
|
|
InventorySlot s = slots[index];
|
|
ChestSlotView v;
|
|
if (s.IsEmpty)
|
|
{
|
|
v = new ChestSlotView { rawId = 0, quantity = 0, uses = -1 };
|
|
}
|
|
else
|
|
{
|
|
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(s.ItemData) : (ushort)0;
|
|
v = new ChestSlotView { rawId = id, quantity = s.Quantity, uses = s.ItemData.HasUses ? s.CurrentUses : -1 };
|
|
}
|
|
slotViews[index] = v;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grants an item back to the requesting player's (client-authoritative) inventory. No-op for
|
|
/// a null item or a non-positive quantity.
|
|
/// </summary>
|
|
private void GrantBack(NetworkConnection conn, ItemData item, int quantity, int uses)
|
|
{
|
|
if (item == null || quantity <= 0) return;
|
|
PlayerInventory inv = ResolveInventory(conn);
|
|
if (inv != null) inv.GrantItemFromServer(item, quantity, uses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the PlayerInventory on the player object owned by the given connection.
|
|
/// </summary>
|
|
private PlayerInventory ResolveInventory(NetworkConnection conn)
|
|
{
|
|
NetworkObject playerObject = conn != null ? conn.FirstObject : null;
|
|
return playerObject != null ? playerObject.GetComponent<PlayerInventory>() : null;
|
|
}
|
|
|
|
#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
|
|
}
|
|
}
|