Inventory: per-player items backbone (DR-026 Phase 0)

Data-driven ItemDatabase catalog + per-player replicated InventorySlot ([GhostField] OwnerSendType.All, a StatModifier twin). Harvest reroutes to the firing player's personal inventory (optional ComponentLookup<GhostOwner> in ResourceHarvestSystem; remainder/un-owned -> ledger); the G-key InventoryDepositRequest RPC moves the bag into the shared ledger the build economy spends. Catalog asset (Aether/Ore/Biomass + Stone Pickaxe) wired into the Gameplay subscene; read-only HUD inventory panel. ushort ItemId subsumes ResourceId; byte Category/Tier baked for gear-tier progression. Session-only (no SaveData bump).

Play-validated host+client: catalog baked into both worlds, the re-baked player ghost carries InventorySlot with a clean handshake, a server write replicates to the client owner, the deposit RPC round-trips, and the HUD renders catalog names. See DR-026.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:43:31 -07:00
parent 1ed2aa46c5
commit 599b9b4255
37 changed files with 848 additions and 1 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9a4badbd1733bfa4daeb671385fad693
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Client
{
/// <summary>
/// Client-only sender for <see cref="InventoryDepositRequest"/> RPCs (move the local player's PERSONAL
/// inventory into the shared base stockpile / global ledger). A one-off action (not per-tick predicted
/// input), so it is an RPC: on the deposit key edge (G = deposit ALL) it creates the request entity
/// targeted at the server connection, and the server applies it authoritatively in
/// <see cref="ProjectM.Server.InventoryDepositSystem"/>. Managed SystemBase because it reads the managed
/// Input System; Input System types are fully qualified and <c>using UnityEngine.InputSystem;</c> is
/// intentionally omitted (that namespace defines a PlayerInput type that collides with
/// <see cref="ProjectM.Simulation.PlayerInput"/>). An editor-only static hook (<see cref="Deposit"/>)
/// drives the same path from execute_code for headless validation without a focused Game view. The wire
/// type is unconditional; only this send SYSTEM is build-time managed.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class InventoryDepositSendSystem : SystemBase
{
#if UNITY_EDITOR
struct PendingDeposit { public ushort ItemId; public int Count; }
static readonly System.Collections.Generic.Queue<PendingDeposit> s_Pending =
new System.Collections.Generic.Queue<PendingDeposit>();
/// <summary>EDITOR / execute_code hook: queue a deposit (ItemId 0 = deposit all; Count &lt;= 0 = all of that item).</summary>
public static void Deposit(ushort itemId = 0, int count = 0) =>
s_Pending.Enqueue(new PendingDeposit { ItemId = itemId, Count = count });
#endif
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
// Need the server connection to target the RPC; bail (keeping any queued ops) until connected.
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
return;
var keyboard = UnityEngine.InputSystem.Keyboard.current;
if (keyboard != null && keyboard.gKey.wasPressedThisFrame)
Send(connection, 0, 0); // G -> deposit everything to the base stockpile
#if UNITY_EDITOR
while (s_Pending.Count > 0)
{
var d = s_Pending.Dequeue();
Send(connection, d.ItemId, d.Count);
}
#endif
}
void Send(Entity connection, ushort itemId, int count)
{
var request = EntityManager.CreateEntity();
EntityManager.AddComponentData(request, new InventoryDepositRequest { ItemId = itemId, Count = count });
EntityManager.AddComponentData(request, new SendRpcCommandRequest { TargetConnection = connection });
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9298a5924b4920b4db8ff2f48732a662
@@ -70,6 +70,9 @@ namespace ProjectM.Client
VisualElement _vignette, _downed;
float _prevHp, _flash;
bool _haveHp;
// personal inventory panel (read-only; toggled with I)
VisualElement _invPanel, _invList;
bool _invOpen;
EntityQuery _huskQuery;
@@ -290,6 +293,35 @@ namespace ProjectM.Client
_vignette.style.display = DisplayStyle.None;
_downed.style.display = DisplayStyle.None;
}
// ---- Personal inventory (read-only; toggle with I, deposit-all with G via InventoryDepositSendSystem) ----
var invKb = UnityEngine.InputSystem.Keyboard.current;
if (invKb != null && invKb.iKey.wasPressedThisFrame) _invOpen = !_invOpen;
if (_invOpen && found)
{
EntityManager.CompleteDependencyBeforeRO<InventorySlot>();
bool haveItemDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
_invPanel.style.display = DisplayStyle.Flex;
_invList.Clear();
int shown = 0;
foreach (var bag in SystemAPI.Query<DynamicBuffer<InventorySlot>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
for (int i = 0; i < bag.Length; i++)
{
var slot = bag[i];
if (slot.ItemId == 0 || slot.Count <= 0) continue;
AddInvRow(ItemName(haveItemDb, itemDb, slot.ItemId), ItemTint(slot.ItemId), slot.Count);
shown++;
}
break;
}
if (shown == 0)
_invList.Add(HudUi.Text("(empty)", 13, MenuUi.SubCol, TextAnchor.MiddleLeft));
}
else
{
_invPanel.style.display = DisplayStyle.None;
}
}
// ---- per-frame helpers ----
@@ -467,6 +499,7 @@ namespace ProjectM.Client
BuildPaletteRow(root);
BuildHintBar(root);
BuildDowned(root);
BuildInventory(root);
}
void BuildVignette(VisualElement root)
@@ -724,6 +757,67 @@ namespace ProjectM.Client
root.Add(_downed);
}
void BuildInventory(VisualElement root)
{
_invPanel = HudUi.Panel(PanelDark);
_invPanel.style.position = Position.Absolute;
_invPanel.style.right = 40; _invPanel.style.bottom = 40;
_invPanel.style.minWidth = 224;
_invPanel.style.paddingLeft = 14; _invPanel.style.paddingRight = 14;
_invPanel.style.paddingTop = 10; _invPanel.style.paddingBottom = 10;
_invPanel.style.alignItems = Align.FlexStart;
_invPanel.pickingMode = PickingMode.Ignore;
var header = HudUi.Display("INVENTORY", 16, AetherCyan, TextAnchor.MiddleLeft);
header.style.marginBottom = 6;
_invPanel.Add(header);
_invList = new VisualElement();
_invList.pickingMode = PickingMode.Ignore;
_invPanel.Add(_invList);
var hint = HudUi.Text("I close - G deposit all at base", 11, MenuUi.SubCol, TextAnchor.MiddleLeft);
hint.style.marginTop = 8;
_invPanel.Add(hint);
_invPanel.style.display = DisplayStyle.None;
root.Add(_invPanel);
}
void AddInvRow(string name, Color tint, int count)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.justifyContent = Justify.SpaceBetween;
row.style.minWidth = 196;
row.style.marginTop = 2;
row.pickingMode = PickingMode.Ignore;
row.Add(HudUi.Text(name, 13, tint, TextAnchor.MiddleLeft));
row.Add(HudUi.Display("x" + count, 13, Color.white, TextAnchor.MiddleRight));
_invList.Add(row);
}
static string ItemName(bool haveDb, ItemDatabase db, ushort id)
{
if (haveDb && db.Value.IsCreated)
{
ref var blob = ref db.Value.Value;
if (blob.TryGetItem(id, out var def)) return def.Name.ToString();
}
if (id == ResourceId.Aether) return "Aether";
if (id == ResourceId.Ore) return "Ore";
if (id == ResourceId.Biomass) return "Biomass";
return "Item " + id;
}
static Color ItemTint(ushort id)
{
if (id == ResourceId.Aether) return AetherCyan;
if (id == ResourceId.Ore) return OreAmber;
if (id == ResourceId.Biomass) return BioGreen;
return new Color(0.85f, 0.85f, 0.9f);
}
static Color ResourceTint(byte resId)
=> resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber;