From 43f355c06b88530072d4c4dcca6eeb489996ba9c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 8 Jun 2026 11:09:25 -0700 Subject: [PATCH] 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) --- Assets/_Project/Items/Item_AetherVest.asset | 25 +++ .../_Project/Items/Item_AetherVest.asset.meta | 8 + Assets/_Project/Items/Item_HeavyCannon.asset | 25 +++ .../Items/Item_HeavyCannon.asset.meta | 8 + Assets/_Project/Items/Item_PowerSigil.asset | 25 +++ .../_Project/Items/Item_PowerSigil.asset.meta | 8 + Assets/_Project/Items/Item_RapidBlaster.asset | 22 +++ .../Items/Item_RapidBlaster.asset.meta | 8 + .../Authoring/Items/ItemDatabaseAuthoring.cs | 16 ++ .../Scripts/Authoring/Items/ItemDefinition.cs | 22 +++ .../Authoring/Player/PlayerAuthoring.cs | 6 + .../Scripts/Client/Economy/EquipSendSystem.cs | 116 +++++++++++++ .../Client/Economy/EquipSendSystem.cs.meta | 2 + .../Scripts/Client/Presentation/HudSystem.cs | 80 ++++++++- .../Scripts/Server/Economy/EquipSystem.cs | 160 ++++++++++++++++++ .../Server/Economy/EquipSystem.cs.meta | 2 + .../Simulation/Items/DefaultAbility.cs | 17 ++ .../Simulation/Items/DefaultAbility.cs.meta | 2 + .../Scripts/Simulation/Items/EquipRequest.cs | 27 +++ .../Simulation/Items/EquipRequest.cs.meta | 2 + .../Scripts/Simulation/Items/EquipSlotId.cs | 31 ++++ .../Simulation/Items/EquipSlotId.cs.meta | 2 + .../Scripts/Simulation/Items/EquipmentSlot.cs | 26 +++ .../Simulation/Items/EquipmentSlot.cs.meta | 2 + .../Scripts/Simulation/Items/InventoryMath.cs | 21 +++ .../Simulation/Items/ItemDatabaseBlob.cs | 38 +++++ Assets/_Project/Scripts/Simulation/Tuning.cs | 10 ++ Assets/_Project/Subscenes/Gameplay.unity | 4 + 28 files changed, 709 insertions(+), 6 deletions(-) create mode 100644 Assets/_Project/Items/Item_AetherVest.asset create mode 100644 Assets/_Project/Items/Item_AetherVest.asset.meta create mode 100644 Assets/_Project/Items/Item_HeavyCannon.asset create mode 100644 Assets/_Project/Items/Item_HeavyCannon.asset.meta create mode 100644 Assets/_Project/Items/Item_PowerSigil.asset create mode 100644 Assets/_Project/Items/Item_PowerSigil.asset.meta create mode 100644 Assets/_Project/Items/Item_RapidBlaster.asset create mode 100644 Assets/_Project/Items/Item_RapidBlaster.asset.meta create mode 100644 Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs create mode 100644 Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Economy/EquipSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Economy/EquipSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items/DefaultAbility.cs create mode 100644 Assets/_Project/Scripts/Simulation/Items/DefaultAbility.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs create mode 100644 Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs create mode 100644 Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs create mode 100644 Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs.meta diff --git a/Assets/_Project/Items/Item_AetherVest.asset b/Assets/_Project/Items/Item_AetherVest.asset new file mode 100644 index 000000000..a5f9c6ae9 --- /dev/null +++ b/Assets/_Project/Items/Item_AetherVest.asset @@ -0,0 +1,25 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 84295e2f852afac4fa4b7384857281d9, type: 3} + m_Name: Item_AetherVest + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition + ItemId: 22 + DisplayName: Aether Vest + Category: 3 + Tier: 1 + StackMax: 1 + EquipSlot: 1 + GrantedAbilityId: 0 + Mods: + - Target: 6 + Op: 1 + Value: 0.15 diff --git a/Assets/_Project/Items/Item_AetherVest.asset.meta b/Assets/_Project/Items/Item_AetherVest.asset.meta new file mode 100644 index 000000000..f5c02c976 --- /dev/null +++ b/Assets/_Project/Items/Item_AetherVest.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b37686ead19d68d45b84e031bfe99f59 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Items/Item_HeavyCannon.asset b/Assets/_Project/Items/Item_HeavyCannon.asset new file mode 100644 index 000000000..d478211e0 --- /dev/null +++ b/Assets/_Project/Items/Item_HeavyCannon.asset @@ -0,0 +1,25 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 84295e2f852afac4fa4b7384857281d9, type: 3} + m_Name: Item_HeavyCannon + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition + ItemId: 21 + DisplayName: Heavy Cannon + Category: 2 + Tier: 1 + StackMax: 1 + EquipSlot: 0 + GrantedAbilityId: 3 + Mods: + - Target: 0 + Op: 0 + Value: 10 diff --git a/Assets/_Project/Items/Item_HeavyCannon.asset.meta b/Assets/_Project/Items/Item_HeavyCannon.asset.meta new file mode 100644 index 000000000..15dabe719 --- /dev/null +++ b/Assets/_Project/Items/Item_HeavyCannon.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 441dbd6676662a2459607f3b75ee821b +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Items/Item_PowerSigil.asset b/Assets/_Project/Items/Item_PowerSigil.asset new file mode 100644 index 000000000..492cb5aa3 --- /dev/null +++ b/Assets/_Project/Items/Item_PowerSigil.asset @@ -0,0 +1,25 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 84295e2f852afac4fa4b7384857281d9, type: 3} + m_Name: Item_PowerSigil + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition + ItemId: 23 + DisplayName: Power Sigil + Category: 3 + Tier: 1 + StackMax: 1 + EquipSlot: 2 + GrantedAbilityId: 0 + Mods: + - Target: 0 + Op: 1 + Value: 0.2 diff --git a/Assets/_Project/Items/Item_PowerSigil.asset.meta b/Assets/_Project/Items/Item_PowerSigil.asset.meta new file mode 100644 index 000000000..35b23b2e1 --- /dev/null +++ b/Assets/_Project/Items/Item_PowerSigil.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c09dc2c47feaa784486e21a3b7e4cb10 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Items/Item_RapidBlaster.asset b/Assets/_Project/Items/Item_RapidBlaster.asset new file mode 100644 index 000000000..445aae871 --- /dev/null +++ b/Assets/_Project/Items/Item_RapidBlaster.asset @@ -0,0 +1,22 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 84295e2f852afac4fa4b7384857281d9, type: 3} + m_Name: Item_RapidBlaster + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition + ItemId: 20 + DisplayName: Rapid Blaster + Category: 2 + Tier: 1 + StackMax: 1 + EquipSlot: 0 + GrantedAbilityId: 2 + Mods: [] diff --git a/Assets/_Project/Items/Item_RapidBlaster.asset.meta b/Assets/_Project/Items/Item_RapidBlaster.asset.meta new file mode 100644 index 000000000..bbdee317d --- /dev/null +++ b/Assets/_Project/Items/Item_RapidBlaster.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b69a26c8de798624b82e6799f8130291 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs b/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs index 75413bad2..272b25298 100644 --- a/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs @@ -40,6 +40,12 @@ namespace ProjectM.Authoring Category = def.Category, Tier = def.Tier, StackMax = def.StackMax, + EquipSlot = def.EquipSlot, + GrantedAbilityId = def.GrantedAbilityId, + Mod0 = ModAt(def, 0), + Mod1 = ModAt(def, 1), + Mod2 = ModAt(def, 2), + Mod3 = ModAt(def, 3), Name = def.DisplayName, }; } @@ -48,6 +54,16 @@ namespace ProjectM.Authoring builder.Dispose(); AddBlobAsset(ref blob, out _); AddComponent(entity, new ItemDatabase { Value = blob }); + + static ItemModSpec ModAt(ItemDefinition def, int i) + { + if (def.Mods != null && i < def.Mods.Count) + { + var m = def.Mods[i]; + return new ItemModSpec { Target = m.Target, Op = m.Op, Value = m.Value }; + } + return new ItemModSpec { Target = 255 }; + } } } } diff --git a/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs b/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs index 922269ee2..d5da0d779 100644 --- a/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs +++ b/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using ProjectM.Simulation; using UnityEngine; @@ -27,5 +28,26 @@ namespace ProjectM.Authoring [Min(1)] [Tooltip("Max units that stack in one inventory slot (1 for non-stacking equipment).")] public int StackMax = 999; + + [Header("Equipment (Phase 1)")] + [Tooltip("EquipSlotId byte: 0=Weapon, 1=Armor, 2=Trinket, 3=Tool, 255=not equippable.")] + public byte EquipSlot = 255; + + [Tooltip("AbilityId granted when equipped in the Weapon slot (0=none): 1=Primary, 2=FastLight, 3=SlowHeavy.")] + public byte GrantedAbilityId = 0; + + [Tooltip("Stat modifiers granted while equipped (first 4 used).")] + public List Mods = new List(); + } + + /// Designer-facing stat-mod grant on an equippable item; the baker writes the first 4 into ItemDefBlob's inline mod slots. + [System.Serializable] + public struct ItemModAuthoring + { + [Tooltip("StatTarget byte: 0=Damage,1=CooldownTicks,2=Range,3=ProjectileSpeed,4=AutoTargetRange,5=AutoTargetCone,6=MoveSpeed,7=TurnRate,8=MaxHealth.")] + public byte Target; + [Tooltip("ModOp byte: 0=Flat, 1=PercentAdd, 2=PercentMult.")] + public byte Op; + public float Value; } } diff --git a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs index 71faae6bb..2c3e5c0f1 100644 --- a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs @@ -60,6 +60,8 @@ namespace ProjectM.Authoring // Data-driven stat refs (replace M2's inlined PlayerMoveStats / AbilityStats values). AddComponent(entity, new CharacterStatsRef { Id = characterId }); AddComponent(entity, new AbilityRef { Id = abilityId }); + // Unarmed/base ability restored on weapon-unequip (AbilityRef.Id mutates when a weapon is equipped). + AddComponent(entity, new DefaultAbility { Id = abilityId }); // Effective stats: zeroed at bake, recomputed every predicted tick by StatRecomputeSystem. AddComponent(entity, new EffectiveAbilityStats()); @@ -69,6 +71,10 @@ namespace ProjectM.Authoring AddBuffer(entity); // Empty replicated personal inventory (server-authoritative; harvest yield + deposit RPC land here). AddBuffer(entity); + // Equipment loadout: one replicated row per slot in FIXED order (buffer index = EquipSlotId), empty. + var equip = AddBuffer(entity); + for (int s = 0; s < EquipSlotId.Count; s++) + equip.Add(new EquipmentSlot { ItemId = 0 }); // Server-only expiry tracker for timed buffs (paired with a StatModifier by SourceId; not replicated). AddBuffer(entity); diff --git a/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs b/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs new file mode 100644 index 000000000..9866d6380 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs @@ -0,0 +1,116 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Client +{ + /// + /// Client-only sender for / RPCs. One-off actions, so + /// RPCs (not per-tick input); the server applies them authoritatively in + /// . 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 using UnityEngine.InputSystem; is + /// omitted (it defines a colliding PlayerInput type). An #if UNITY_EDITOR 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. + /// + [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 s_Pending = + new System.Collections.Generic.Queue(); + + /// EDITOR / execute_code hook: queue an equip of from the bag. + public static void Equip(ushort itemId) => + s_Pending.Enqueue(new PendingEquip { Unequip = false, ItemId = itemId }); + + /// EDITOR / execute_code hook: queue an unequip of (an EquipSlotId). + public static void Unequip(byte slot) => + s_Pending.Enqueue(new PendingEquip { Unequip = true, Slot = slot }); + + protected override void OnCreate() + { + RequireForUpdate(); + } + + protected override void OnUpdate() + { + if (!SystemAPI.TryGetSingletonEntity(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; + } + + /// Resolve the Nth EQUIPPABLE distinct item in the local player's bag (skips resources via the catalog). + ushort NthEquippableBagItem(int n) + { + bool haveDb = SystemAPI.TryGetSingleton(out var db); + foreach (var bag in SystemAPI.Query>().WithAll()) + { + 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 }); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs.meta b/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs.meta new file mode 100644 index 000000000..99070f18c --- /dev/null +++ b/Assets/_Project/Scripts/Client/Economy/EquipSendSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7a9bec24b84553746aec834c1b56dfd6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index f7c2921dd..0d222f4c9 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -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(); + EntityManager.CompleteDependencyBeforeRO(); bool haveItemDb = SystemAPI.TryGetSingleton(out var itemDb); _invPanel.style.display = DisplayStyle.Flex; + _invList.Clear(); int shown = 0; foreach (var bag in SystemAPI.Query>() @@ -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>() + .WithAll()) + { + 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(_ => 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(_ => 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; diff --git a/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs b/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs new file mode 100644 index 000000000..79bf28e4f --- /dev/null +++ b/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs @@ -0,0 +1,160 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server-authoritative equipment handler ( / RPCs). + /// Resolves the sender's player (SourceConnection -> NetworkId -> GhostOwner, the AbilityUpgradeSystem / + /// InventoryDepositSystem owner-map idiom) and applies the change IN-PLACE: moves the item between the + /// personal bag and the loadout (buffer index = slot), + /// sets .Id from the Weapon slot (restoring on + /// weapon-unequip), and adds/strips the item's inline stat mods as s tagged by a + /// per-slot SourceId (Tuning.EquipSourceIdBase + slot), stripped TARGET-AGNOSTICALLY via + /// . + /// + /// Effects are EVENT-DRIVEN (applied once here): AbilityRef + StatModifier are [GhostField]s re-folded by the + /// predicted StatRecomputeSystem every tick and replicated to the owner, so the swap is prediction-correct + /// (DebugModifierInjectionSystem.CycleAbility is the precedent) and survives respawn (the entity persists). + /// Atomicity: an equip into an occupied slot verifies the bag can hold the swapped-out item BEFORE any + /// withdrawal and rejects otherwise — no item loss (the co-op-placement commit-in-place rule). Plain server + /// SimulationSystemGroup (NOT predicted -> applied once, no rollback double-apply); only the request entity + /// destroy is deferred to the ECB. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + public partial struct EquipSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + var builder = new EntityQueryBuilder(Allocator.Temp) + .WithAny().WithAll(); + state.RequireForUpdate(state.GetEntityQuery(builder)); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var itemDb = SystemAPI.GetSingleton(); + + var playerByConn = new NativeHashMap(8, Allocator.Temp); + foreach (var (owner, entity) in + SystemAPI.Query>() + .WithAll().WithEntityAccess()) + playerByConn[owner.ValueRO.NetworkId] = entity; + + var ecb = new EntityCommandBuffer(Allocator.Temp); + + foreach (var (request, receive, requestEntity) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + if (TryResolvePlayer(ref state, playerByConn, receive.ValueRO.SourceConnection, out var player)) + HandleEquip(ref state, itemDb, player, request.ValueRO.ItemId); + ecb.DestroyEntity(requestEntity); + } + + foreach (var (request, receive, requestEntity) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + if (TryResolvePlayer(ref state, playerByConn, receive.ValueRO.SourceConnection, out var player)) + HandleUnequip(ref state, itemDb, player, request.ValueRO.Slot); + ecb.DestroyEntity(requestEntity); + } + + ecb.Playback(state.EntityManager); + playerByConn.Dispose(); + } + + static bool TryResolvePlayer(ref SystemState state, NativeHashMap map, Entity conn, out Entity player) + { + player = Entity.Null; + return state.EntityManager.HasComponent(conn) + && map.TryGetValue(state.EntityManager.GetComponentData(conn).Value, out player); + } + + static void HandleEquip(ref SystemState state, ItemDatabase itemDb, Entity player, ushort itemId) + { + ref var db = ref itemDb.Value.Value; + if (!db.TryGetItem(itemId, out var def)) return; + byte slot = def.EquipSlot; + if (slot >= EquipSlotId.Count) return; // not equippable (255 or out of range) + + var bag = state.EntityManager.GetBuffer(player); + if (InventoryMath.CountOf(bag, itemId) < 1) return; // the sender isn't carrying it + + var slots = state.EntityManager.GetBuffer(player); + ushort oldItem = slots[slot].ItemId; + + // Atomicity: if the slot is occupied, the bag MUST be able to hold the swapped-out item before we + // touch anything; reject the whole equip otherwise so the old item is never lost. + if (oldItem != 0 && !InventoryMath.CanDeposit(bag, oldItem, 1, StackMaxOf(ref db, oldItem), Tuning.InventoryMaxSlots)) + return; + + // Commit in-place. + InventoryMath.Withdraw(bag, itemId, 1); + if (oldItem != 0) + { + InventoryMath.Deposit(bag, oldItem, 1, StackMaxOf(ref db, oldItem), Tuning.InventoryMaxSlots); + StripSlotEffects(ref state, player, slot); + } + + slots[slot] = new EquipmentSlot { ItemId = itemId }; + ApplySlotEffects(ref state, player, slot, def); + } + + static void HandleUnequip(ref SystemState state, ItemDatabase itemDb, Entity player, byte slot) + { + if (slot >= EquipSlotId.Count) return; + ref var db = ref itemDb.Value.Value; + + var slots = state.EntityManager.GetBuffer(player); + ushort item = slots[slot].ItemId; + if (item == 0) return; // nothing equipped + + var bag = state.EntityManager.GetBuffer(player); + if (!InventoryMath.CanDeposit(bag, item, 1, StackMaxOf(ref db, item), Tuning.InventoryMaxSlots)) + return; // bag full -> can't unequip (no item loss) + + InventoryMath.Deposit(bag, item, 1, StackMaxOf(ref db, item), Tuning.InventoryMaxSlots); + slots[slot] = new EquipmentSlot { ItemId = 0 }; + StripSlotEffects(ref state, player, slot); + } + + static void ApplySlotEffects(ref SystemState state, Entity player, byte slot, ItemDefBlob def) + { + // Weapon slot drives the active ability (swaps prefab + base stats via StatRecomputeSystem). + if (slot == EquipSlotId.Weapon && def.GrantedAbilityId != 0) + state.EntityManager.SetComponentData(player, new AbilityRef { Id = def.GrantedAbilityId }); + + var mods = state.EntityManager.GetBuffer(player); + uint sourceId = Tuning.EquipSourceIdBase + (uint)slot; + for (int i = 0; i < ItemDefBlob.MaxMods; i++) + { + var m = def.GetMod(i); + if (m.Target == 255) continue; + mods.Add(new StatModifier { Target = m.Target, Op = m.Op, Value = m.Value, SourceId = sourceId }); + } + } + + static void StripSlotEffects(ref SystemState state, Entity player, byte slot) + { + var mods = state.EntityManager.GetBuffer(player); + TimedModifierUtil.RemoveBySourceId(mods, Tuning.EquipSourceIdBase + (uint)slot); + + // Weapon slot: restore the unarmed/base ability. + if (slot == EquipSlotId.Weapon) + { + byte fallback = state.EntityManager.GetComponentData(player).Id; + state.EntityManager.SetComponentData(player, new AbilityRef { Id = fallback }); + } + } + + static int StackMaxOf(ref ItemDatabaseBlob db, ushort itemId) + => db.TryGetItem(itemId, out var d) && d.StackMax > 0 ? d.StackMax : 1; + } +} diff --git a/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs.meta b/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs.meta new file mode 100644 index 000000000..8f5601297 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Economy/EquipSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 187144e115a815c4fae51eaa9e95012f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/DefaultAbility.cs b/Assets/_Project/Scripts/Simulation/Items/DefaultAbility.cs new file mode 100644 index 000000000..ba9a9a218 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/DefaultAbility.cs @@ -0,0 +1,17 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// The player's "unarmed" / base ability id, baked from PlayerAuthoring.PrimaryAbility. Restored into + /// .Id by EquipSystem when a weapon is unequipped. NOT replicated (it never changes, + /// so a [GhostField] would waste snapshot bytes and there is no client consumer). AbilityRef itself cannot + /// serve double duty because EquipSystem overwrites AbilityRef.Id when a weapon is equipped — this preserves + /// the immutable default to fall back to. Server-read only. + /// + public struct DefaultAbility : IComponentData + { + /// The (as a byte) the player fires with no weapon equipped. + public byte Id; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Items/DefaultAbility.cs.meta b/Assets/_Project/Scripts/Simulation/Items/DefaultAbility.cs.meta new file mode 100644 index 000000000..0ccdf44ed --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/DefaultAbility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c6831e7f8bb98d448917f88dcbe12db \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs b/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs new file mode 100644 index 000000000..0822e181c --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs @@ -0,0 +1,27 @@ +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Client -> server request to equip an item from the sender's personal inventory into the slot the + /// catalog assigns it (). A one-off action, so it is an RPC (not a + /// per-tick predicted input); applied exactly once server-only in the plain SimulationSystemGroup. Carries + /// only the ItemId — the server derives the target slot from the catalog, so a client can't force a weapon + /// into the armor slot. Unconditional wire type (no #if); the server resolves the sender via SourceConnection. + /// + public struct EquipRequest : IRpcCommand + { + /// The inventory item to equip; the server resolves its slot + effects from the catalog. + public ushort ItemId; + } + + /// + /// Client -> server request to unequip whatever occupies (an ), + /// returning the item to the personal inventory and stripping its effects. Unconditional wire type. + /// + public struct UnequipRequest : IRpcCommand + { + /// The to clear. + public byte Slot; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs.meta new file mode 100644 index 000000000..6178d2e29 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/EquipRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ad5475188aa05de45a231f937b19f069 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs b/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs new file mode 100644 index 000000000..32f59bbda --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs @@ -0,0 +1,31 @@ +namespace ProjectM.Simulation +{ + /// + /// Equipment-slot ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard). The player's + /// buffer holds one row PER slot in this fixed order (the buffer index IS the + /// slot), so these double as both the catalog's value and the buffer + /// index. The Weapon slot grants its item's into AbilityRef; + /// every slot grants the item's inline stat mods. is reserved for Phase 2 (tool-gated + /// harvesting). 255 = not equippable. + /// + public static class EquipSlotId + { + /// Weapon: grants the item's ability (AbilityRef.Id) + its stat mods. + public const byte Weapon = 0; + + /// Armor: grants the item's stat mods. + public const byte Armor = 1; + + /// Trinket: grants the item's stat mods. + public const byte Trinket = 2; + + /// Tool (axe/pickaxe) — reserved for Phase 2 tool-gated harvesting. + public const byte Tool = 3; + + /// Number of equipment slots (the baked buffer length). + public const byte Count = 4; + + /// Sentinel: this item is not equippable. + public const byte None = 255; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs.meta b/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs.meta new file mode 100644 index 000000000..773deb6c2 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/EquipSlotId.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ce8addc5ae6832a449d1b6ad459ab21a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs b/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs new file mode 100644 index 000000000..538b174f7 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs @@ -0,0 +1,26 @@ +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// One equipment slot on the player. The per-player buffer holds exactly + /// rows in fixed slot order (the buffer INDEX is the slot — Weapon=0/Armor=1/Trinket=2/Tool=3), so only the + /// equipped item id needs to replicate; there is no separate Slot field to desync. A [GhostField] + /// buffer (a / twin) + /// so the owning client's HUD can show its loadout; the server is the SOLE writer (EquipSystem). + /// + /// The actual effects — AbilityRef.Id from the Weapon slot + StatModifiers per slot — are applied + /// EVENT-DRIVEN by EquipSystem (once per equip/unequip), NOT re-derived from this buffer each tick; this + /// buffer is the replicated record of WHAT is equipped (HUD-facing + persistence-ready), not the effect. + /// NOTE: adding this [GhostField] buffer changes the player ghost serialization hash → the player + /// prefab/subscene MUST be re-baked consistently in both worlds (see ). + /// + [GhostComponent(OwnerSendType = SendToOwnerType.All)] + [InternalBufferCapacity(4)] + public struct EquipmentSlot : IBufferElementData + { + /// Item equipped in this slot (0 = empty). The buffer INDEX is the . + [GhostField] public ushort ItemId; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs.meta b/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs.meta new file mode 100644 index 000000000..e0ec06559 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/EquipmentSlot.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ac0f8812307d3ef43bbed63d1e3fb737 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs b/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs index 92af415e2..9183a387c 100644 --- a/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs +++ b/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs @@ -76,6 +76,27 @@ namespace ProjectM.Simulation return taken; } + /// + /// Non-mutating check: would depositing of FULLY fit + /// (top-up existing stacks + new stacks within )? Used by the equip swap to + /// guarantee the swapped-out item has room BEFORE any withdrawal (no item loss). Mirrors Deposit's space math. + /// + public static bool CanDeposit(DynamicBuffer buffer, ushort itemId, int count, int stackMax, int maxSlots) + { + if (count <= 0) return true; + if (itemId == 0) return false; + if (stackMax < 1) stackMax = int.MaxValue; + + long space = 0; + for (int i = 0; i < buffer.Length; i++) + if (buffer[i].ItemId == itemId) + space += stackMax - buffer[i].Count; + int freeSlots = maxSlots - buffer.Length; + if (freeSlots > 0) + space += (long)freeSlots * stackMax; + return space >= count; + } + /// Total quantity of across all stacks (0 if absent). public static int CountOf(DynamicBuffer buffer, ushort itemId) { diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs b/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs index eda274d99..34bfcde5f 100644 --- a/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs +++ b/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs @@ -19,6 +19,20 @@ namespace ProjectM.Simulation /// is baked NOW because the project's progression axis is gear tiers, so Phase 2/3 tier gating is a /// content-only edit. /// + /// One stat-modifier grant on an equippable item, stored INLINE (NOT a nested BlobArray: a nested + /// BlobArray is a relative-offset pointer that corrupts the moment + /// returns the containing BY VALUE — the same copy hazard the class note warns about). + /// Target 255 = unused. + public struct ItemModSpec + { + /// as a byte; 255 = unused slot. + public byte Target; + /// as a byte. + public byte Op; + /// Magnitude (flat amount or fractional percent). + public float Value; + } + public struct ItemDefBlob { /// Stable item id (ushort; 1-3 reserved for the existing resources, keep stable for saves). @@ -33,8 +47,32 @@ namespace ProjectM.Simulation /// Max units that stack in a single inventory slot (1 for non-stacking equipment). public int StackMax; + /// Equip slot (see ); 255 = not equippable. + public byte EquipSlot; + + /// AbilityId granted when equipped in the Weapon slot (0 = none); the equip handler writes it into AbilityRef.Id. + public byte GrantedAbilityId; + + /// Up to INLINE stat-mod grants applied while equipped (Target 255 = unused). Inline, not a nested BlobArray. + public ItemModSpec Mod0, Mod1, Mod2, Mod3; + /// Designer-facing display name (shown in the HUD inventory panel). public FixedString64Bytes Name; + + /// Number of inline mod slots. + public const int MaxMods = 4; + + /// Indexed access to the inline mod slots (returns a copy — safe, ItemModSpec holds no BlobArray). + public ItemModSpec GetMod(int i) + { + switch (i) + { + case 0: return Mod0; + case 1: return Mod1; + case 2: return Mod2; + default: return Mod3; + } + } } /// diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index ee597812f..a08bf5f81 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -64,5 +64,15 @@ namespace ProjectM.Simulation /// Default per-slot stack cap when an item has no ItemDatabase entry (the catalog is optional at runtime). public const int DefaultStackMax = 999; + + // ---- Equipment stat-mod SourceIds (EquipSystem) ---- + // One DISTINCT sentinel per slot: slot i tags its mods with EquipSourceIdBase + i. All of a slot's + // inline mods share that one id and are stripped target-agnostically via + // TimedModifierUtil.RemoveBySourceId on unequip/swap. Full StatModifier SourceId map (keep DISJOINT): + // 0u = pickups + debug-injection; 0x00A0E711 = ability-damage upgrade; 0x00DEB061 = debug stat command; + // 0x00E91000.. = equipment (4 slots). + + /// Base for per-slot equipment SourceIds; slot i tags its mods with EquipSourceIdBase + i. + public const uint EquipSourceIdBase = 0x00E91000u; } } diff --git a/Assets/_Project/Subscenes/Gameplay.unity b/Assets/_Project/Subscenes/Gameplay.unity index 749e47058..c3f806061 100644 --- a/Assets/_Project/Subscenes/Gameplay.unity +++ b/Assets/_Project/Subscenes/Gameplay.unity @@ -1480,8 +1480,12 @@ MonoBehaviour: m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDatabaseAuthoring Items: - {fileID: 11400000, guid: 938a882b6fbd50d4c9a051ddabc5829b, type: 2} + - {fileID: 11400000, guid: b37686ead19d68d45b84e031bfe99f59, type: 2} - {fileID: 11400000, guid: a2a0e6d3ae0218d458d2b5305891ce89, type: 2} + - {fileID: 11400000, guid: 441dbd6676662a2459607f3b75ee821b, type: 2} - {fileID: 11400000, guid: 4ed399a329eb6d847921aef05bde213d, type: 2} + - {fileID: 11400000, guid: c09dc2c47feaa784486e21a3b7e4cb10, type: 2} + - {fileID: 11400000, guid: b69a26c8de798624b82e6799f8130291, type: 2} - {fileID: 11400000, guid: 31ec40cfc6e81ef4ab9bbce57a40621e, type: 2} --- !u!4 &722706770 Transform: