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:
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b37686ead19d68d45b84e031bfe99f59
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 441dbd6676662a2459607f3b75ee821b
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c09dc2c47feaa784486e21a3b7e4cb10
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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: []
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b69a26c8de798624b82e6799f8130291
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -40,6 +40,12 @@ namespace ProjectM.Authoring
|
|||||||
Category = def.Category,
|
Category = def.Category,
|
||||||
Tier = def.Tier,
|
Tier = def.Tier,
|
||||||
StackMax = def.StackMax,
|
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,
|
Name = def.DisplayName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -48,6 +54,16 @@ namespace ProjectM.Authoring
|
|||||||
builder.Dispose();
|
builder.Dispose();
|
||||||
AddBlobAsset(ref blob, out _);
|
AddBlobAsset(ref blob, out _);
|
||||||
AddComponent(entity, new ItemDatabase { Value = blob });
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using ProjectM.Simulation;
|
using ProjectM.Simulation;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
@@ -27,5 +28,26 @@ namespace ProjectM.Authoring
|
|||||||
[Min(1)]
|
[Min(1)]
|
||||||
[Tooltip("Max units that stack in one inventory slot (1 for non-stacking equipment).")]
|
[Tooltip("Max units that stack in one inventory slot (1 for non-stacking equipment).")]
|
||||||
public int StackMax = 999;
|
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<ItemModAuthoring> Mods = new List<ItemModAuthoring>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Designer-facing stat-mod grant on an equippable item; the baker writes the first 4 into ItemDefBlob's inline mod slots.</summary>
|
||||||
|
[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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ namespace ProjectM.Authoring
|
|||||||
// Data-driven stat refs (replace M2's inlined PlayerMoveStats / AbilityStats values).
|
// Data-driven stat refs (replace M2's inlined PlayerMoveStats / AbilityStats values).
|
||||||
AddComponent(entity, new CharacterStatsRef { Id = characterId });
|
AddComponent(entity, new CharacterStatsRef { Id = characterId });
|
||||||
AddComponent(entity, new AbilityRef { Id = abilityId });
|
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.
|
// Effective stats: zeroed at bake, recomputed every predicted tick by StatRecomputeSystem.
|
||||||
AddComponent(entity, new EffectiveAbilityStats());
|
AddComponent(entity, new EffectiveAbilityStats());
|
||||||
@@ -69,6 +71,10 @@ namespace ProjectM.Authoring
|
|||||||
AddBuffer<StatModifier>(entity);
|
AddBuffer<StatModifier>(entity);
|
||||||
// Empty replicated personal inventory (server-authoritative; harvest yield + deposit RPC land here).
|
// Empty replicated personal inventory (server-authoritative; harvest yield + deposit RPC land here).
|
||||||
AddBuffer<InventorySlot>(entity);
|
AddBuffer<InventorySlot>(entity);
|
||||||
|
// Equipment loadout: one replicated row per slot in FIXED order (buffer index = EquipSlotId), empty.
|
||||||
|
var equip = AddBuffer<EquipmentSlot>(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).
|
// Server-only expiry tracker for timed buffs (paired with a StatModifier by SourceId; not replicated).
|
||||||
AddBuffer<TimedModifier>(entity);
|
AddBuffer<TimedModifier>(entity);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
float _prevHp, _flash;
|
||||||
bool _haveHp;
|
bool _haveHp;
|
||||||
// personal inventory panel (read-only; toggled with I)
|
// personal inventory panel (read-only; toggled with I)
|
||||||
VisualElement _invPanel, _invList;
|
VisualElement _invPanel, _invList, _equipList;
|
||||||
bool _invOpen;
|
bool _invOpen;
|
||||||
|
|
||||||
EntityQuery _huskQuery;
|
EntityQuery _huskQuery;
|
||||||
@@ -299,8 +299,10 @@ namespace ProjectM.Client
|
|||||||
if (_invOpen && found)
|
if (_invOpen && found)
|
||||||
{
|
{
|
||||||
EntityManager.CompleteDependencyBeforeRO<InventorySlot>();
|
EntityManager.CompleteDependencyBeforeRO<InventorySlot>();
|
||||||
|
EntityManager.CompleteDependencyBeforeRO<EquipmentSlot>();
|
||||||
bool haveItemDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
|
bool haveItemDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
|
||||||
_invPanel.style.display = DisplayStyle.Flex;
|
_invPanel.style.display = DisplayStyle.Flex;
|
||||||
|
|
||||||
_invList.Clear();
|
_invList.Clear();
|
||||||
int shown = 0;
|
int shown = 0;
|
||||||
foreach (var bag in SystemAPI.Query<DynamicBuffer<InventorySlot>>()
|
foreach (var bag in SystemAPI.Query<DynamicBuffer<InventorySlot>>()
|
||||||
@@ -310,13 +312,27 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
var slot = bag[i];
|
var slot = bag[i];
|
||||||
if (slot.ItemId == 0 || slot.Count <= 0) continue;
|
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++;
|
shown++;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (shown == 0)
|
if (shown == 0)
|
||||||
_invList.Add(HudUi.Text("(empty)", 13, MenuUi.SubCol, TextAnchor.MiddleLeft));
|
_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
|
else
|
||||||
{
|
{
|
||||||
@@ -776,7 +792,15 @@ namespace ProjectM.Client
|
|||||||
_invList.pickingMode = PickingMode.Ignore;
|
_invList.pickingMode = PickingMode.Ignore;
|
||||||
_invPanel.Add(_invList);
|
_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;
|
hint.style.marginTop = 8;
|
||||||
_invPanel.Add(hint);
|
_invPanel.Add(hint);
|
||||||
|
|
||||||
@@ -784,16 +808,22 @@ namespace ProjectM.Client
|
|||||||
root.Add(_invPanel);
|
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();
|
var row = new VisualElement();
|
||||||
row.style.flexDirection = FlexDirection.Row;
|
row.style.flexDirection = FlexDirection.Row;
|
||||||
row.style.justifyContent = Justify.SpaceBetween;
|
row.style.justifyContent = Justify.SpaceBetween;
|
||||||
row.style.minWidth = 196;
|
row.style.minWidth = 196;
|
||||||
row.style.marginTop = 2;
|
row.style.marginTop = 2;
|
||||||
row.pickingMode = PickingMode.Ignore;
|
row.Add(HudUi.Text(name + (equippable ? " (equip)" : ""), 13, tint, TextAnchor.MiddleLeft));
|
||||||
row.Add(HudUi.Text(name, 13, tint, TextAnchor.MiddleLeft));
|
|
||||||
row.Add(HudUi.Display("x" + count, 13, Color.white, TextAnchor.MiddleRight));
|
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);
|
_invList.Add(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,6 +847,44 @@ namespace ProjectM.Client
|
|||||||
if (id == ResourceId.Biomass) return BioGreen;
|
if (id == ResourceId.Biomass) return BioGreen;
|
||||||
return new Color(0.85f, 0.85f, 0.9f);
|
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)
|
static Color ResourceTint(byte resId)
|
||||||
=> resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber;
|
=> resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber;
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Burst;
|
||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Server
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server-authoritative equipment handler (<see cref="EquipRequest"/> / <see cref="UnequipRequest"/> 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 <see cref="InventorySlot"/> bag and the <see cref="EquipmentSlot"/> loadout (buffer index = slot),
|
||||||
|
/// sets <see cref="AbilityRef"/>.Id from the Weapon slot (restoring <see cref="DefaultAbility"/> on
|
||||||
|
/// weapon-unequip), and adds/strips the item's inline stat mods as <see cref="StatModifier"/>s tagged by a
|
||||||
|
/// per-slot SourceId (<c>Tuning.EquipSourceIdBase + slot</c>), stripped TARGET-AGNOSTICALLY via
|
||||||
|
/// <see cref="TimedModifierUtil.RemoveBySourceId"/>.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[BurstCompile]
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
|
public partial struct EquipSystem : ISystem
|
||||||
|
{
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnCreate(ref SystemState state)
|
||||||
|
{
|
||||||
|
state.RequireForUpdate<ItemDatabase>();
|
||||||
|
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||||
|
.WithAny<EquipRequest, UnequipRequest>().WithAll<ReceiveRpcCommandRequest>();
|
||||||
|
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||||
|
}
|
||||||
|
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnUpdate(ref SystemState state)
|
||||||
|
{
|
||||||
|
var itemDb = SystemAPI.GetSingleton<ItemDatabase>();
|
||||||
|
|
||||||
|
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||||
|
foreach (var (owner, entity) in
|
||||||
|
SystemAPI.Query<RefRO<GhostOwner>>()
|
||||||
|
.WithAll<PlayerTag, InventorySlot, EquipmentSlot>().WithEntityAccess())
|
||||||
|
playerByConn[owner.ValueRO.NetworkId] = entity;
|
||||||
|
|
||||||
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
|
|
||||||
|
foreach (var (request, receive, requestEntity) in
|
||||||
|
SystemAPI.Query<RefRO<EquipRequest>, RefRO<ReceiveRpcCommandRequest>>().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<UnequipRequest>, RefRO<ReceiveRpcCommandRequest>>().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<int, Entity> map, Entity conn, out Entity player)
|
||||||
|
{
|
||||||
|
player = Entity.Null;
|
||||||
|
return state.EntityManager.HasComponent<NetworkId>(conn)
|
||||||
|
&& map.TryGetValue(state.EntityManager.GetComponentData<NetworkId>(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<InventorySlot>(player);
|
||||||
|
if (InventoryMath.CountOf(bag, itemId) < 1) return; // the sender isn't carrying it
|
||||||
|
|
||||||
|
var slots = state.EntityManager.GetBuffer<EquipmentSlot>(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<EquipmentSlot>(player);
|
||||||
|
ushort item = slots[slot].ItemId;
|
||||||
|
if (item == 0) return; // nothing equipped
|
||||||
|
|
||||||
|
var bag = state.EntityManager.GetBuffer<InventorySlot>(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<StatModifier>(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<StatModifier>(player);
|
||||||
|
TimedModifierUtil.RemoveBySourceId(mods, Tuning.EquipSourceIdBase + (uint)slot);
|
||||||
|
|
||||||
|
// Weapon slot: restore the unarmed/base ability.
|
||||||
|
if (slot == EquipSlotId.Weapon)
|
||||||
|
{
|
||||||
|
byte fallback = state.EntityManager.GetComponentData<DefaultAbility>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 187144e115a815c4fae51eaa9e95012f
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Unity.Entities;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The player's "unarmed" / base ability id, baked from PlayerAuthoring.PrimaryAbility. Restored into
|
||||||
|
/// <see cref="AbilityRef"/>.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.
|
||||||
|
/// </summary>
|
||||||
|
public struct DefaultAbility : IComponentData
|
||||||
|
{
|
||||||
|
/// <summary>The <see cref="AbilityId"/> (as a byte) the player fires with no weapon equipped.</summary>
|
||||||
|
public byte Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4c6831e7f8bb98d448917f88dcbe12db
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Client -> server request to equip an item from the sender's personal inventory into the slot the
|
||||||
|
/// catalog assigns it (<see cref="ItemDefBlob.EquipSlot"/>). 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.
|
||||||
|
/// </summary>
|
||||||
|
public struct EquipRequest : IRpcCommand
|
||||||
|
{
|
||||||
|
/// <summary>The inventory item to equip; the server resolves its slot + effects from the catalog.</summary>
|
||||||
|
public ushort ItemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client -> server request to unequip whatever occupies <see cref="Slot"/> (an <see cref="EquipSlotId"/>),
|
||||||
|
/// returning the item to the personal inventory and stripping its effects. Unconditional wire type.
|
||||||
|
/// </summary>
|
||||||
|
public struct UnequipRequest : IRpcCommand
|
||||||
|
{
|
||||||
|
/// <summary>The <see cref="EquipSlotId"/> to clear.</summary>
|
||||||
|
public byte Slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ad5475188aa05de45a231f937b19f069
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Equipment-slot ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard). The player's
|
||||||
|
/// <see cref="EquipmentSlot"/> buffer holds one row PER slot in this fixed order (the buffer index IS the
|
||||||
|
/// slot), so these double as both the catalog's <see cref="ItemDefBlob.EquipSlot"/> value and the buffer
|
||||||
|
/// index. The Weapon slot grants its item's <see cref="ItemDefBlob.GrantedAbilityId"/> into AbilityRef;
|
||||||
|
/// every slot grants the item's inline stat mods. <see cref="Tool"/> is reserved for Phase 2 (tool-gated
|
||||||
|
/// harvesting). 255 = not equippable.
|
||||||
|
/// </summary>
|
||||||
|
public static class EquipSlotId
|
||||||
|
{
|
||||||
|
/// <summary>Weapon: grants the item's ability (AbilityRef.Id) + its stat mods.</summary>
|
||||||
|
public const byte Weapon = 0;
|
||||||
|
|
||||||
|
/// <summary>Armor: grants the item's stat mods.</summary>
|
||||||
|
public const byte Armor = 1;
|
||||||
|
|
||||||
|
/// <summary>Trinket: grants the item's stat mods.</summary>
|
||||||
|
public const byte Trinket = 2;
|
||||||
|
|
||||||
|
/// <summary>Tool (axe/pickaxe) — reserved for Phase 2 tool-gated harvesting.</summary>
|
||||||
|
public const byte Tool = 3;
|
||||||
|
|
||||||
|
/// <summary>Number of equipment slots (the baked <see cref="EquipmentSlot"/> buffer length).</summary>
|
||||||
|
public const byte Count = 4;
|
||||||
|
|
||||||
|
/// <summary>Sentinel: this item is not equippable.</summary>
|
||||||
|
public const byte None = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ce8addc5ae6832a449d1b6ad459ab21a
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One equipment slot on the player. The per-player buffer holds exactly <see cref="EquipSlotId.Count"/>
|
||||||
|
/// 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]
|
||||||
|
/// <see cref="SendToOwnerType.All"/> buffer (a <see cref="StatModifier"/>/<see cref="InventorySlot"/> 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 <see cref="InventorySlot"/>).
|
||||||
|
/// </summary>
|
||||||
|
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
|
||||||
|
[InternalBufferCapacity(4)]
|
||||||
|
public struct EquipmentSlot : IBufferElementData
|
||||||
|
{
|
||||||
|
/// <summary>Item equipped in this slot (0 = empty). The buffer INDEX is the <see cref="EquipSlotId"/>.</summary>
|
||||||
|
[GhostField] public ushort ItemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ac0f8812307d3ef43bbed63d1e3fb737
|
||||||
@@ -76,6 +76,27 @@ namespace ProjectM.Simulation
|
|||||||
return taken;
|
return taken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-mutating check: would depositing <paramref name="count"/> of <paramref name="itemId"/> FULLY fit
|
||||||
|
/// (top-up existing stacks + new stacks within <paramref name="maxSlots"/>)? Used by the equip swap to
|
||||||
|
/// guarantee the swapped-out item has room BEFORE any withdrawal (no item loss). Mirrors Deposit's space math.
|
||||||
|
/// </summary>
|
||||||
|
public static bool CanDeposit(DynamicBuffer<InventorySlot> 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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Total quantity of <paramref name="itemId"/> across all stacks (0 if absent).</summary>
|
/// <summary>Total quantity of <paramref name="itemId"/> across all stacks (0 if absent).</summary>
|
||||||
public static int CountOf(DynamicBuffer<InventorySlot> buffer, ushort itemId)
|
public static int CountOf(DynamicBuffer<InventorySlot> buffer, ushort itemId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
/// is baked NOW because the project's progression axis is gear tiers, so Phase 2/3 tier gating is a
|
||||||
/// content-only edit.
|
/// content-only edit.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>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 <see cref="ItemDatabaseBlob.TryGetItem"/>
|
||||||
|
/// returns the containing <see cref="ItemDefBlob"/> BY VALUE — the same copy hazard the class note warns about).
|
||||||
|
/// Target 255 = unused.</summary>
|
||||||
|
public struct ItemModSpec
|
||||||
|
{
|
||||||
|
/// <summary><see cref="StatTarget"/> as a byte; 255 = unused slot.</summary>
|
||||||
|
public byte Target;
|
||||||
|
/// <summary><see cref="ModOp"/> as a byte.</summary>
|
||||||
|
public byte Op;
|
||||||
|
/// <summary>Magnitude (flat amount or fractional percent).</summary>
|
||||||
|
public float Value;
|
||||||
|
}
|
||||||
|
|
||||||
public struct ItemDefBlob
|
public struct ItemDefBlob
|
||||||
{
|
{
|
||||||
/// <summary>Stable item id (ushort; 1-3 reserved for the existing resources, keep stable for saves).</summary>
|
/// <summary>Stable item id (ushort; 1-3 reserved for the existing resources, keep stable for saves).</summary>
|
||||||
@@ -33,8 +47,32 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>Max units that stack in a single inventory slot (1 for non-stacking equipment).</summary>
|
/// <summary>Max units that stack in a single inventory slot (1 for non-stacking equipment).</summary>
|
||||||
public int StackMax;
|
public int StackMax;
|
||||||
|
|
||||||
|
/// <summary>Equip slot (see <see cref="EquipSlotId"/>); 255 = not equippable.</summary>
|
||||||
|
public byte EquipSlot;
|
||||||
|
|
||||||
|
/// <summary>AbilityId granted when equipped in the Weapon slot (0 = none); the equip handler writes it into AbilityRef.Id.</summary>
|
||||||
|
public byte GrantedAbilityId;
|
||||||
|
|
||||||
|
/// <summary>Up to <see cref="MaxMods"/> INLINE stat-mod grants applied while equipped (Target 255 = unused). Inline, not a nested BlobArray.</summary>
|
||||||
|
public ItemModSpec Mod0, Mod1, Mod2, Mod3;
|
||||||
|
|
||||||
/// <summary>Designer-facing display name (shown in the HUD inventory panel).</summary>
|
/// <summary>Designer-facing display name (shown in the HUD inventory panel).</summary>
|
||||||
public FixedString64Bytes Name;
|
public FixedString64Bytes Name;
|
||||||
|
|
||||||
|
/// <summary>Number of inline mod slots.</summary>
|
||||||
|
public const int MaxMods = 4;
|
||||||
|
|
||||||
|
/// <summary>Indexed access to the inline mod slots (returns a copy — safe, ItemModSpec holds no BlobArray).</summary>
|
||||||
|
public ItemModSpec GetMod(int i)
|
||||||
|
{
|
||||||
|
switch (i)
|
||||||
|
{
|
||||||
|
case 0: return Mod0;
|
||||||
|
case 1: return Mod1;
|
||||||
|
case 2: return Mod2;
|
||||||
|
default: return Mod3;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -64,5 +64,15 @@ namespace ProjectM.Simulation
|
|||||||
|
|
||||||
/// <summary>Default per-slot stack cap when an item has no ItemDatabase entry (the catalog is optional at runtime).</summary>
|
/// <summary>Default per-slot stack cap when an item has no ItemDatabase entry (the catalog is optional at runtime).</summary>
|
||||||
public const int DefaultStackMax = 999;
|
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).
|
||||||
|
|
||||||
|
/// <summary>Base for per-slot equipment SourceIds; slot i tags its mods with <c>EquipSourceIdBase + i</c>.</summary>
|
||||||
|
public const uint EquipSourceIdBase = 0x00E91000u;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1480,8 +1480,12 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDatabaseAuthoring
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDatabaseAuthoring
|
||||||
Items:
|
Items:
|
||||||
- {fileID: 11400000, guid: 938a882b6fbd50d4c9a051ddabc5829b, type: 2}
|
- {fileID: 11400000, guid: 938a882b6fbd50d4c9a051ddabc5829b, type: 2}
|
||||||
|
- {fileID: 11400000, guid: b37686ead19d68d45b84e031bfe99f59, type: 2}
|
||||||
- {fileID: 11400000, guid: a2a0e6d3ae0218d458d2b5305891ce89, type: 2}
|
- {fileID: 11400000, guid: a2a0e6d3ae0218d458d2b5305891ce89, type: 2}
|
||||||
|
- {fileID: 11400000, guid: 441dbd6676662a2459607f3b75ee821b, type: 2}
|
||||||
- {fileID: 11400000, guid: 4ed399a329eb6d847921aef05bde213d, 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}
|
- {fileID: 11400000, guid: 31ec40cfc6e81ef4ab9bbce57a40621e, type: 2}
|
||||||
--- !u!4 &722706770
|
--- !u!4 &722706770
|
||||||
Transform:
|
Transform:
|
||||||
|
|||||||
Reference in New Issue
Block a user