(Feat) Add Build

This commit is contained in:
2026-07-22 12:56:13 +02:00
parent 3657b870d8
commit 0052b0d98e
244 changed files with 35752 additions and 3112 deletions
+182 -212
View File
@@ -10,8 +10,8 @@ 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.
/// 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"/>[])
@@ -72,7 +72,7 @@ namespace Ashwild.Storage
#region Events
/// <summary>
/// Fired when a slot's replicated view changes so the open <see cref="ChestUI"/> can refresh
/// 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;
@@ -154,16 +154,17 @@ namespace Ashwild.Storage
public string InteractionPrompt => $"Ouvrir {DisplayName}";
/// <summary>
/// Opens the shared chest window bound to this chest. Runs on the interacting client only.
/// Opens the inventory window in chest mode, bound to this chest. Runs on the interacting client
/// only.
/// </summary>
public void Interact()
{
if (ChestUI.Instance == null)
if (InventoryUI.Instance == null)
{
Debug.LogError("[Chest] No ChestUI found in the scene — cannot open the chest.", this);
Debug.LogError("[Chest] No InventoryUI found in the scene — cannot open the chest.", this);
return;
}
ChestUI.Instance.Open(this);
InventoryUI.Instance.OpenChest(this);
}
#endregion
@@ -198,52 +199,94 @@ namespace Ashwild.Storage
#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.
/// 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 RequestDepositToSlot(int inventoryIndex, int chestSlot)
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;
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)
if (fromContainer == SlotContainer.Chest)
{
Debug.LogError($"[Chest] '{item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this);
if (!TryGetSlot(index, 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;
}
QuickWithdrawServerRpc(index);
return;
}
if (chestSlot >= 0 && chestSlot < slotViews.Count)
SlotContent content = inv.ReadSlot(index);
if (content.IsEmpty) return;
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(content.Item) : (ushort)0;
if (id == 0)
{
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;
}
}
Debug.LogError($"[Chest] '{content.Item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this);
return;
}
inv.RemoveItem(inventoryIndex, quantity);
RequestDepositServerRpc(chestSlot, id, quantity, uses);
inv.WriteSlot(index, SlotContent.Empty);
QuickDepositServerRpc(id, content.Quantity, content.Uses, index);
}
/// <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.
/// 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()
{
@@ -251,50 +294,18 @@ namespace Ashwild.Storage
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);
}
RequestQuickTransfer(SlotContainer.Inventory, i);
}
/// <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.
/// 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++)
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);
RequestQuickTransfer(SlotContainer.Chest, i);
}
#endregion
@@ -302,110 +313,80 @@ namespace Ashwild.Storage
#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.
/// 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 RequestDepositServerRpc(int chestSlot, ushort id, int quantity, int uses, NetworkConnection conn = null)
private void MoveWithinServerRpc(int fromIndex, int toIndex)
{
ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null;
if (item == null || quantity <= 0) return;
if (fromIndex < 0 || fromIndex >= slots.Length) return;
if (toIndex < 0 || toIndex >= slots.Length) return;
if (chestSlot < 0 || chestSlot >= slots.Length)
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)
{
ServerDepositAuto(item, quantity, uses, conn);
GrantBack(conn, playerContent, inventoryIndex);
return;
}
InventorySlot target = slots[chestSlot];
SlotContent chestContent = ReadServerSlot(chestIndex);
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;
if (chestIsSource) SlotTransfer.Move(ref chestContent, ref playerContent);
else SlotTransfer.Move(ref playerContent, ref chestContent);
int place = PlaceableCount(item, quantity);
target.Set(item, place, uses);
WriteView(chestSlot);
GrantBack(conn, item, quantity - place, uses);
GrantBack(conn, displaced, displacedQty, displacedUses);
}
WriteServerSlot(chestIndex, chestContent);
GrantBack(conn, playerContent, inventoryIndex);
}
/// <summary>
/// Server-side withdraw. Removes the whole stack from the chest slot and grants it to the
/// requesting player.
/// 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 RequestWithdrawServerRpc(int chestSlot, int quantity, NetworkConnection conn = null)
private void QuickDepositServerRpc(ushort id, int quantity, int uses, int originIndex, NetworkConnection conn = null)
{
if (chestSlot < 0 || chestSlot >= slots.Length) return;
ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null;
SlotContent incoming = SlotContent.Of(item, quantity, uses);
if (incoming.IsEmpty) 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);
AutoPlace(ref incoming);
GrantBack(conn, incoming, originIndex);
}
/// <summary>
/// Server-side move within the chest: merges into a matching stack, otherwise swaps the two
/// slots (uses preserved). Never touches any inventory.
/// Server-side quick withdraw: empties the chest slot and lets the player's inventory auto-place it.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void RequestMoveWithinServerRpc(int fromSlot, int toSlot, NetworkConnection conn = null)
private void QuickWithdrawServerRpc(int chestIndex, NetworkConnection conn = null)
{
if (fromSlot < 0 || fromSlot >= slots.Length) return;
if (toSlot < 0 || toSlot >= slots.Length) return;
if (fromSlot == toSlot) return;
if (chestIndex < 0 || chestIndex >= slots.Length) return;
InventorySlot a = slots[fromSlot];
if (a.IsEmpty) return;
InventorySlot b = slots[toSlot];
SlotContent content = ReadServerSlot(chestIndex);
if (content.IsEmpty) return;
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);
WriteServerSlot(chestIndex, SlotContent.Empty);
GrantBack(conn, content, -1);
}
#endregion
@@ -413,84 +394,73 @@ namespace Ashwild.Storage
#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.
/// 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 ServerDepositAuto(ItemData item, int quantity, int uses, NetworkConnection conn)
private void AutoPlace(ref SlotContent incoming)
{
int remaining = quantity;
for (int i = 0; i < slots.Length && !incoming.IsEmpty; i++)
if (!slots[i].IsEmpty) StackIntoSlot(i, ref incoming);
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);
for (int i = 0; i < slots.Length && !incoming.IsEmpty; i++)
if (slots[i].IsEmpty) StackIntoSlot(i, ref incoming);
}
/// <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.
/// Pushes as much of the incoming stack as one chest slot accepts, writing it back when it changed.
/// </summary>
private int PlaceableCount(ItemData item, int quantity)
private void StackIntoSlot(int index, ref SlotContent incoming)
{
int cap = item.HasUses ? 1 : (item.IsStackable ? item.MaxStackSize : 1);
return Mathf.Min(quantity, cap);
SlotContent target = ReadServerSlot(index);
if (!SlotTransfer.TryStack(ref incoming, ref target)) return;
WriteServerSlot(index, target);
}
/// <summary>
/// Pushes the server model of a slot into its replicated view.
/// Reads a chest slot from the authoritative model as a container-agnostic snapshot.
/// </summary>
private void WriteView(int index)
private SlotContent ReadServerSlot(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;
if (s.IsEmpty) return SlotContent.Empty;
return SlotContent.Of(s.ItemData, s.Quantity, s.CurrentUses);
}
/// <summary>
/// Grants an item back to the requesting player's (client-authoritative) inventory. No-op for
/// a null item or a non-positive quantity.
/// 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 GrantBack(NetworkConnection conn, ItemData item, int quantity, int uses)
private void WriteServerSlot(int index, SlotContent content)
{
if (item == null || quantity <= 0) return;
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(item, quantity, uses);
if (inv != null) inv.GrantItemFromServer(content.Item, content.Quantity, content.Uses, preferredIndex, isTransfer: true);
}
/// <summary>