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
This commit is contained in:
2026-07-25 19:42:26 +02:00
954 changed files with 999772 additions and 26193 deletions
+99 -25
View File
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using FishNet.Connection;
@@ -47,6 +48,18 @@ namespace Ashwild.Inventory
private InventorySlot[] slots;
private int selectedHotbarIndex;
/// <summary>
/// Scratch copy of the slots used by the capacity dry run, kept as a field so a per-swing
/// capacity check does not allocate. Never holds meaningful state between calls.
/// </summary>
private SlotContent[] fitSnapshot;
/// <summary>
/// One-element buffer so the single-item <see cref="CanFit"/> can reuse the multi-item dry run
/// without allocating an array on every call.
/// </summary>
private readonly SlotContent[] singleFitBuffer = new SlotContent[1];
public int InventorySize => inventorySize;
public int HotbarSize => hotbarSize;
public int SelectedHotbarIndex => selectedHotbarIndex;
@@ -156,6 +169,12 @@ namespace Ashwild.Inventory
/// <summary>
/// Runs on the owning client: resolves the granted item and adds it locally, restoring its
/// remaining uses and honouring the slot the player aimed at when one was requested.
///
/// A grant has already left its source by the time it arrives, so anything that does not fit must
/// not simply evaporate the way it used to. The interactions that can be refused pre-check their
/// capacity before asking the server, which makes an overflow here a last-resort case (the
/// inventory filled up while the request was in flight); it is put back into the world at the
/// player's feet, announced, and logged — never destroyed.
/// </summary>
[TargetRpc]
private void TargetGrantItem(NetworkConnection conn, ushort itemId, int quantity, int uses, int preferredIndex, bool isTransfer)
@@ -167,7 +186,13 @@ namespace Ashwild.Inventory
return;
}
AddItem(item, quantity, uses, preferredIndex, isTransfer);
AddItem(item, quantity, uses, preferredIndex, isTransfer, out int leftover);
if (leftover <= 0) return;
Debug.LogWarning($"[PlayerInventory] Inventory full — {leftover}x '{item.ItemName}' could not be " +
"stored and was returned to the world.", this);
PlayerEvents.RaiseInteractionRefused("Inventory full");
SpawnInWorld(item, leftover, uses);
}
#endregion
@@ -269,7 +294,19 @@ namespace Ashwild.Inventory
/// pulling an item a co-op partner left in a chest is a genuine first acquisition.
/// </summary>
public bool AddItem(ItemData item, int quantity = 1, int uses = -1, int preferredIndex = -1, bool isTransfer = false)
=> AddItem(item, quantity, uses, preferredIndex, isTransfer, out _);
/// <summary>
/// Same as <see cref="AddItem(ItemData,int,int,int,bool)"/> but reports how many units could not be
/// placed. Callers that received the stack from the server need that number: whatever is left over
/// has already left its source (the harvestable is damaged, the pickup is claimed) and would simply
/// cease to exist if it were dropped on the floor here, which is exactly how loot used to go
/// missing without a single log line.
/// </summary>
public bool AddItem(ItemData item, int quantity, int uses, int preferredIndex, bool isTransfer, out int leftover)
{
leftover = quantity;
if (item == null)
{
Debug.LogError("[PlayerInventory] AddItem called with no ItemData — pickup ignored.", this);
@@ -287,7 +324,9 @@ namespace Ashwild.Inventory
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
if (slots[i].IsEmpty) StackInto(i, ref incoming);
int added = quantity - (incoming.IsEmpty ? 0 : incoming.Quantity);
leftover = incoming.IsEmpty ? 0 : incoming.Quantity;
int added = quantity - leftover;
if (added > 0)
{
onItemAdded?.Invoke(item, added);
@@ -317,23 +356,43 @@ namespace Ashwild.Inventory
{
if (item == null) return false;
SlotContent incoming = SlotContent.Of(item, quantity, -1);
singleFitBuffer[0] = SlotContent.Of(item, quantity, -1);
return CanFitAll(singleFitBuffer);
}
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
/// <summary>
/// Returns whether several stacks would fit *together*, without mutating anything. Asking
/// <see cref="CanFit"/> once per item is not equivalent and quietly over-promises: with a single
/// empty slot left, two different items each answer "yes" on their own, then the second one has
/// nowhere to go. Harvest loot rolls several items at once, so it needs the combined answer — the
/// dry run therefore places each stack into a running snapshot of the slots, exactly as the real
/// add would, and fails as soon as one leftover cannot be placed.
/// </summary>
public bool CanFitAll(IReadOnlyList<SlotContent> incoming)
{
if (incoming == null || incoming.Count == 0) return true;
if (fitSnapshot == null || fitSnapshot.Length != inventorySize)
fitSnapshot = new SlotContent[inventorySize];
for (int i = 0; i < inventorySize; i++)
fitSnapshot[i] = ReadSlot(i);
for (int n = 0; n < incoming.Count; n++)
{
if (slots[i].IsEmpty) continue;
SlotContent target = ReadSlot(i);
SlotTransfer.TryStack(ref incoming, ref target);
SlotContent pending = incoming[n];
if (pending.IsEmpty) continue;
for (int i = 0; i < inventorySize && !pending.IsEmpty; i++)
if (!fitSnapshot[i].IsEmpty) SlotTransfer.TryStack(ref pending, ref fitSnapshot[i]);
for (int i = 0; i < inventorySize && !pending.IsEmpty; i++)
if (fitSnapshot[i].IsEmpty) SlotTransfer.TryStack(ref pending, ref fitSnapshot[i]);
if (!pending.IsEmpty) return false;
}
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
{
if (!slots[i].IsEmpty) continue;
SlotContent target = SlotContent.Empty;
SlotTransfer.TryStack(ref incoming, ref target);
}
return incoming.IsEmpty;
return true;
}
public void RemoveItem(int index, int quantity = 1)
@@ -408,21 +467,36 @@ namespace Ashwild.Inventory
ItemData item = slots[index].ItemData;
int uses = item.HasUses ? slots[index].CurrentUses : -1;
Transform origin = dropOrigin != null ? dropOrigin : transform;
if (item.WorldPrefab != null && origin != null && PickableRegistry.Instance != null)
{
Vector3 dropPos = origin.position + origin.forward * dropForwardDistance;
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0;
if (id != 0)
PickableRegistry.Instance.RequestDropServerRpc(id, quantity, uses, dropPos, origin.rotation);
else
Debug.LogWarning($"[PlayerInventory] '{item.ItemName}' is not in the ItemDatabase — drop not spawned. Run Rebuild Item Database.", this);
}
SpawnInWorld(item, quantity, uses);
RemoveItem(index, quantity);
PlayerEvents.RaiseItemDropped(item, quantity);
}
/// <summary>
/// Asks the server to spawn a stack in front of the player, without touching the inventory. Shared
/// by the deliberate drop (which removes the stack first) and by the overflow safety net (whose
/// stack was never stored), so both go through the same authoritative drop path.
/// </summary>
private void SpawnInWorld(ItemData item, int quantity, int uses)
{
if (item == null || quantity <= 0) return;
Transform origin = dropOrigin != null ? dropOrigin : transform;
if (item.WorldPrefab == null || origin == null || PickableRegistry.Instance == null) return;
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0;
if (id == 0)
{
Debug.LogWarning($"[PlayerInventory] '{item.ItemName}' is not in the ItemDatabase — drop not spawned. Run Rebuild Item Database.", this);
return;
}
Vector3 dropPos = origin.position + origin.forward * dropForwardDistance;
PickableRegistry.Instance.RequestDropServerRpc(id, quantity, uses, dropPos, origin.rotation);
}
public void SelectHotbarSlot(int index)
{
if (index < 0 || index >= hotbarSize) return;