Equipment: weapon-granted abilities + gear mods (DR-027 Phase 1)

Equipment slots reusing the AbilityRef/StatModifier machinery: EquipmentSlot [GhostField] buffer (index=slot), server-only event-driven EquipSystem (RPC). Weapon -> AbilityRef.Id swaps the attack (prefab + base stats, prediction-correct); gear -> StatModifiers tagged a reserved per-slot EquipSourceId, stripped target-agnostically via RemoveBySourceId. Item mods are INLINE on ItemDefBlob (a nested BlobArray reads empty under the by-value TryGetItem copy). Atomic equip-over swap (no item loss); DefaultAbility restores the unarmed ability on weapon-unequip. Client keys + build-safe hooks; HUD equipment panel + click-to-equip. 4 catalog weapon/gear items wired + re-baked.

Play-validated host+client: weapon equip swaps AbilityRef on both worlds, gear folds into EffectiveCharacterStats, unequip reverses + restores DefaultAbility, all replicated to the owner. See DR-027.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:09:25 -07:00
parent 510c12a980
commit 43f355c06b
28 changed files with 709 additions and 6 deletions
@@ -0,0 +1,116 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Client
{
/// <summary>
/// Client-only sender for <see cref="EquipRequest"/> / <see cref="UnequipRequest"/> RPCs. One-off actions, so
/// RPCs (not per-tick input); the server applies them authoritatively in
/// <see cref="ProjectM.Server.EquipSystem"/>. Number keys 1-9 equip the Nth EQUIPPABLE item in the local bag
/// (resources are skipped via the catalog); U unequips the weapon. Managed SystemBase because it reads the
/// managed Input System; Input System types are fully qualified and <c>using UnityEngine.InputSystem;</c> is
/// omitted (it defines a colliding PlayerInput type). An <c>#if UNITY_EDITOR</c> static hook drives the same
/// path from execute_code for headless validation. The HUD click-to-equip (HudSystem) is the primary UX;
/// these keys are the reachable fallback. Wire types are unconditional; only this send SYSTEM is gated.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class EquipSendSystem : SystemBase
{
struct PendingEquip { public bool Unequip; public ushort ItemId; public byte Slot; }
static readonly System.Collections.Generic.Queue<PendingEquip> s_Pending =
new System.Collections.Generic.Queue<PendingEquip>();
/// <summary>EDITOR / execute_code hook: queue an equip of <paramref name="itemId"/> from the bag.</summary>
public static void Equip(ushort itemId) =>
s_Pending.Enqueue(new PendingEquip { Unequip = false, ItemId = itemId });
/// <summary>EDITOR / execute_code hook: queue an unequip of <paramref name="slot"/> (an EquipSlotId).</summary>
public static void Unequip(byte slot) =>
s_Pending.Enqueue(new PendingEquip { Unequip = true, Slot = slot });
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
return;
var keyboard = UnityEngine.InputSystem.Keyboard.current;
if (keyboard != null)
{
int n = NumberKeyPressed(keyboard);
if (n >= 0)
{
ushort itemId = NthEquippableBagItem(n);
if (itemId != 0) SendEquip(connection, itemId);
}
if (keyboard.uKey.wasPressedThisFrame)
SendUnequip(connection, EquipSlotId.Weapon);
}
while (s_Pending.Count > 0)
{
var p = s_Pending.Dequeue();
if (p.Unequip) SendUnequip(connection, p.Slot);
else SendEquip(connection, p.ItemId);
}
}
static int NumberKeyPressed(UnityEngine.InputSystem.Keyboard kb)
{
if (kb.digit1Key.wasPressedThisFrame) return 0;
if (kb.digit2Key.wasPressedThisFrame) return 1;
if (kb.digit3Key.wasPressedThisFrame) return 2;
if (kb.digit4Key.wasPressedThisFrame) return 3;
if (kb.digit5Key.wasPressedThisFrame) return 4;
if (kb.digit6Key.wasPressedThisFrame) return 5;
if (kb.digit7Key.wasPressedThisFrame) return 6;
if (kb.digit8Key.wasPressedThisFrame) return 7;
if (kb.digit9Key.wasPressedThisFrame) return 8;
return -1;
}
/// <summary>Resolve the Nth EQUIPPABLE distinct item in the local player's bag (skips resources via the catalog).</summary>
ushort NthEquippableBagItem(int n)
{
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var db);
foreach (var bag in SystemAPI.Query<DynamicBuffer<InventorySlot>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
int idx = 0;
for (int i = 0; i < bag.Length; i++)
{
ushort id = bag[i].ItemId;
if (id == 0 || bag[i].Count <= 0) continue;
if (haveDb && db.Value.IsCreated)
{
ref var b = ref db.Value.Value;
if (!b.TryGetItem(id, out var def) || def.EquipSlot >= EquipSlotId.Count) continue;
}
if (idx == n) return id;
idx++;
}
break;
}
return 0;
}
void SendEquip(Entity connection, ushort itemId)
{
var req = EntityManager.CreateEntity();
EntityManager.AddComponentData(req, new EquipRequest { ItemId = itemId });
EntityManager.AddComponentData(req, new SendRpcCommandRequest { TargetConnection = connection });
}
void SendUnequip(Entity connection, byte slot)
{
var req = EntityManager.CreateEntity();
EntityManager.AddComponentData(req, new UnequipRequest { Slot = slot });
EntityManager.AddComponentData(req, new SendRpcCommandRequest { TargetConnection = connection });
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a9bec24b84553746aec834c1b56dfd6
@@ -71,7 +71,7 @@ namespace ProjectM.Client
float _prevHp, _flash;
bool _haveHp;
// personal inventory panel (read-only; toggled with I)
VisualElement _invPanel, _invList;
VisualElement _invPanel, _invList, _equipList;
bool _invOpen;
EntityQuery _huskQuery;
@@ -299,8 +299,10 @@ namespace ProjectM.Client
if (_invOpen && found)
{
EntityManager.CompleteDependencyBeforeRO<InventorySlot>();
EntityManager.CompleteDependencyBeforeRO<EquipmentSlot>();
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>>()
@@ -310,13 +312,27 @@ namespace ProjectM.Client
{
var slot = bag[i];
if (slot.ItemId == 0 || slot.Count <= 0) continue;
AddInvRow(ItemName(haveItemDb, itemDb, slot.ItemId), ItemTint(slot.ItemId), slot.Count);
AddInvRow(slot.ItemId, ItemName(haveItemDb, itemDb, slot.ItemId), ItemTint(slot.ItemId), slot.Count,
IsEquippable(haveItemDb, itemDb, slot.ItemId));
shown++;
}
break;
}
if (shown == 0)
_invList.Add(HudUi.Text("(empty)", 13, MenuUi.SubCol, TextAnchor.MiddleLeft));
_equipList.Clear();
foreach (var slots in SystemAPI.Query<DynamicBuffer<EquipmentSlot>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
for (byte s = 0; s < EquipSlotId.Count && s < slots.Length; s++)
{
ushort id = slots[s].ItemId;
string label = SlotName(s) + ": " + (id == 0 ? "-" : ItemName(haveItemDb, itemDb, id));
AddEquipRow(s, label, id != 0);
}
break;
}
}
else
{
@@ -776,7 +792,15 @@ namespace ProjectM.Client
_invList.pickingMode = PickingMode.Ignore;
_invPanel.Add(_invList);
var hint = HudUi.Text("I close - G deposit all at base", 11, MenuUi.SubCol, TextAnchor.MiddleLeft);
var equipHeader = HudUi.Display("EQUIPMENT", 14, AetherCyan, TextAnchor.MiddleLeft);
equipHeader.style.marginTop = 8; equipHeader.style.marginBottom = 4;
_invPanel.Add(equipHeader);
_equipList = new VisualElement();
_equipList.pickingMode = PickingMode.Ignore;
_invPanel.Add(_equipList);
var hint = HudUi.Text("I close - click item=equip / slot=unequip - G deposit", 11, MenuUi.SubCol, TextAnchor.MiddleLeft);
hint.style.marginTop = 8;
_invPanel.Add(hint);
@@ -784,16 +808,22 @@ namespace ProjectM.Client
root.Add(_invPanel);
}
void AddInvRow(string name, Color tint, int count)
void AddInvRow(ushort itemId, string name, Color tint, int count, bool equippable)
{
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.Text(name + (equippable ? " (equip)" : ""), 13, tint, TextAnchor.MiddleLeft));
row.Add(HudUi.Display("x" + count, 13, Color.white, TextAnchor.MiddleRight));
if (equippable)
{
row.pickingMode = PickingMode.Position;
ushort id = itemId;
row.RegisterCallback<ClickEvent>(_ => EquipSendSystem.Equip(id));
}
else row.pickingMode = PickingMode.Ignore;
_invList.Add(row);
}
@@ -817,6 +847,44 @@ namespace ProjectM.Client
if (id == ResourceId.Biomass) return BioGreen;
return new Color(0.85f, 0.85f, 0.9f);
}
static bool IsEquippable(bool haveDb, ItemDatabase db, ushort id)
{
if (!haveDb || !db.Value.IsCreated) return false;
ref var b = ref db.Value.Value;
return b.TryGetItem(id, out var def) && def.EquipSlot < EquipSlotId.Count;
}
static string SlotName(byte slot)
{
switch (slot)
{
case EquipSlotId.Weapon: return "Weapon";
case EquipSlotId.Armor: return "Armor";
case EquipSlotId.Trinket: return "Trinket";
case EquipSlotId.Tool: return "Tool";
default: return "Slot " + slot;
}
}
void AddEquipRow(byte slot, string label, bool occupied)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.justifyContent = Justify.SpaceBetween;
row.style.minWidth = 196;
row.style.marginTop = 2;
row.Add(HudUi.Text(label, 13, occupied ? AetherCyan : MenuUi.SubCol, TextAnchor.MiddleLeft));
if (occupied)
{
row.pickingMode = PickingMode.Position;
byte s = slot;
row.RegisterCallback<ClickEvent>(_ => EquipSendSystem.Unequip(s));
row.Add(HudUi.Text("unequip", 11, new Color(1f, 0.6f, 0.5f), TextAnchor.MiddleRight));
}
else row.pickingMode = PickingMode.Ignore;
_equipList.Add(row);
}
static Color ResourceTint(byte resId)
=> resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber;