Inventory: per-player items backbone (DR-026 Phase 0)
Data-driven ItemDatabase catalog + per-player replicated InventorySlot ([GhostField] OwnerSendType.All, a StatModifier twin). Harvest reroutes to the firing player's personal inventory (optional ComponentLookup<GhostOwner> in ResourceHarvestSystem; remainder/un-owned -> ledger); the G-key InventoryDepositRequest RPC moves the bag into the shared ledger the build economy spends. Catalog asset (Aether/Ore/Biomass + Stone Pickaxe) wired into the Gameplay subscene; read-only HUD inventory panel. ushort ItemId subsumes ResourceId; byte Category/Tier baked for gear-tier progression. Session-only (no SaveData bump). Play-validated host+client: catalog baked into both worlds, the re-baked player ghost carries InventorySlot with a clean handshake, a server write replicates to the client owner, the deposit RPC round-trips, and the HUD renders catalog names. See DR-026. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7994449a235d329439443860dfb4ec82
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
%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_Aether
|
||||||
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition
|
||||||
|
ItemId: 1
|
||||||
|
DisplayName: Aether
|
||||||
|
Category: 0
|
||||||
|
Tier: 0
|
||||||
|
StackMax: 999
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 938a882b6fbd50d4c9a051ddabc5829b
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
%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_Biomass
|
||||||
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition
|
||||||
|
ItemId: 3
|
||||||
|
DisplayName: Biomass
|
||||||
|
Category: 0
|
||||||
|
Tier: 0
|
||||||
|
StackMax: 999
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a2a0e6d3ae0218d458d2b5305891ce89
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
%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_Ore
|
||||||
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition
|
||||||
|
ItemId: 2
|
||||||
|
DisplayName: Ore
|
||||||
|
Category: 0
|
||||||
|
Tier: 0
|
||||||
|
StackMax: 999
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4ed399a329eb6d847921aef05bde213d
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
%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_StonePickaxe
|
||||||
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition
|
||||||
|
ItemId: 10
|
||||||
|
DisplayName: Stone Pickaxe
|
||||||
|
Category: 1
|
||||||
|
Tier: 1
|
||||||
|
StackMax: 1
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 31ec40cfc6e81ef4ab9bbce57a40621e
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2251d22676b666d48ac5e9e32b70391c
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ProjectM.Authoring
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bakes the designer-authored item definitions into a single ItemDatabase blob singleton (immutable,
|
||||||
|
/// shared, Burst-fast), mirroring AbilityDatabaseAuthoring. Place ONE in the gameplay subscene; it streams
|
||||||
|
/// identically into the client and server worlds (config, not replicated). DependsOn each definition so a
|
||||||
|
/// value change re-bakes the blob. Runtime lookup is ID-keyed (ItemDatabaseBlob.TryGetItem), so the list
|
||||||
|
/// order here does not matter and inserting an item never renumbers existing ids.
|
||||||
|
/// </summary>
|
||||||
|
public class ItemDatabaseAuthoring : MonoBehaviour
|
||||||
|
{
|
||||||
|
[Tooltip("All item definitions in the game (resources + tools/gear). Looked up at runtime by ItemId.")]
|
||||||
|
public List<ItemDefinition> Items = new List<ItemDefinition>();
|
||||||
|
|
||||||
|
private class DatabaseBaker : Baker<ItemDatabaseAuthoring>
|
||||||
|
{
|
||||||
|
public override void Bake(ItemDatabaseAuthoring authoring)
|
||||||
|
{
|
||||||
|
var entity = GetEntity(TransformUsageFlags.None);
|
||||||
|
|
||||||
|
int count = authoring.Items != null ? authoring.Items.Count : 0;
|
||||||
|
|
||||||
|
var builder = new BlobBuilder(Allocator.Temp);
|
||||||
|
ref var root = ref builder.ConstructRoot<ItemDatabaseBlob>();
|
||||||
|
var arr = builder.Allocate(ref root.Items, count);
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var def = authoring.Items[i];
|
||||||
|
if (def == null) { arr[i] = default; continue; }
|
||||||
|
DependsOn(def);
|
||||||
|
arr[i] = new ItemDefBlob
|
||||||
|
{
|
||||||
|
ItemId = (ushort)def.ItemId,
|
||||||
|
Category = def.Category,
|
||||||
|
Tier = def.Tier,
|
||||||
|
StackMax = def.StackMax,
|
||||||
|
Name = def.DisplayName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var blob = builder.CreateBlobAssetReference<ItemDatabaseBlob>(Allocator.Persistent);
|
||||||
|
builder.Dispose();
|
||||||
|
AddBlobAsset(ref blob, out _);
|
||||||
|
AddComponent(entity, new ItemDatabase { Value = blob });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5ee44dc3bc9f3164592195d4068be8d1
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ProjectM.Authoring
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Designer-facing definition of one item (resource, tool, weapon, gear, consumable). Numeric/byte fields
|
||||||
|
/// are baked into the ItemDatabase blob (immutable, Burst-fast runtime config). Category and Tier are
|
||||||
|
/// BYTES, not enums, to dodge BOTH the MCP enum-drop hazard (manage_* silently drop enum fields when set
|
||||||
|
/// via tooling) AND the cross-assembly enum-in-Burst hazard. UI icon/description are deferred to a later
|
||||||
|
/// managed lookup keyed by id, exactly like AbilityDefinition.
|
||||||
|
/// </summary>
|
||||||
|
[CreateAssetMenu(menuName = "Project M/Item Definition", fileName = "Item_")]
|
||||||
|
public class ItemDefinition : ScriptableObject
|
||||||
|
{
|
||||||
|
[Tooltip("Stable item id (ushort range). Keep 1=Aether, 2=Ore, 3=Biomass; reserve >3 for new items; 0 = none.")]
|
||||||
|
public int ItemId = 4;
|
||||||
|
|
||||||
|
public string DisplayName = "Item";
|
||||||
|
|
||||||
|
[Tooltip("ItemCategory byte: 0=Resource, 1=Tool, 2=Weapon, 3=Gear, 4=Consumable.")]
|
||||||
|
public byte Category = ItemCategory.Resource;
|
||||||
|
|
||||||
|
[Tooltip("Progression tier (0 = base). Higher-tier tools harvest higher-tier nodes / hit harder.")]
|
||||||
|
public byte Tier = 0;
|
||||||
|
|
||||||
|
[Min(1)]
|
||||||
|
[Tooltip("Max units that stack in one inventory slot (1 for non-stacking equipment).")]
|
||||||
|
public int StackMax = 999;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 84295e2f852afac4fa4b7384857281d9
|
||||||
@@ -67,6 +67,8 @@ namespace ProjectM.Authoring
|
|||||||
|
|
||||||
// Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative).
|
// Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative).
|
||||||
AddBuffer<StatModifier>(entity);
|
AddBuffer<StatModifier>(entity);
|
||||||
|
// Empty replicated personal inventory (server-authoritative; harvest yield + deposit RPC land here).
|
||||||
|
AddBuffer<InventorySlot>(entity);
|
||||||
// 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,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9a4badbd1733bfa4daeb671385fad693
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Client-only sender for <see cref="InventoryDepositRequest"/> RPCs (move the local player's PERSONAL
|
||||||
|
/// inventory into the shared base stockpile / global ledger). A one-off action (not per-tick predicted
|
||||||
|
/// input), so it is an RPC: on the deposit key edge (G = deposit ALL) it creates the request entity
|
||||||
|
/// targeted at the server connection, and the server applies it authoritatively in
|
||||||
|
/// <see cref="ProjectM.Server.InventoryDepositSystem"/>. Managed SystemBase because it reads the managed
|
||||||
|
/// Input System; Input System types are fully qualified and <c>using UnityEngine.InputSystem;</c> is
|
||||||
|
/// intentionally omitted (that namespace defines a PlayerInput type that collides with
|
||||||
|
/// <see cref="ProjectM.Simulation.PlayerInput"/>). An editor-only static hook (<see cref="Deposit"/>)
|
||||||
|
/// drives the same path from execute_code for headless validation without a focused Game view. The wire
|
||||||
|
/// type is unconditional; only this send SYSTEM is build-time managed.
|
||||||
|
/// </summary>
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
|
public partial class InventoryDepositSendSystem : SystemBase
|
||||||
|
{
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
struct PendingDeposit { public ushort ItemId; public int Count; }
|
||||||
|
|
||||||
|
static readonly System.Collections.Generic.Queue<PendingDeposit> s_Pending =
|
||||||
|
new System.Collections.Generic.Queue<PendingDeposit>();
|
||||||
|
|
||||||
|
/// <summary>EDITOR / execute_code hook: queue a deposit (ItemId 0 = deposit all; Count <= 0 = all of that item).</summary>
|
||||||
|
public static void Deposit(ushort itemId = 0, int count = 0) =>
|
||||||
|
s_Pending.Enqueue(new PendingDeposit { ItemId = itemId, Count = count });
|
||||||
|
#endif
|
||||||
|
|
||||||
|
protected override void OnCreate()
|
||||||
|
{
|
||||||
|
RequireForUpdate<NetworkId>();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnUpdate()
|
||||||
|
{
|
||||||
|
// Need the server connection to target the RPC; bail (keeping any queued ops) until connected.
|
||||||
|
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var keyboard = UnityEngine.InputSystem.Keyboard.current;
|
||||||
|
if (keyboard != null && keyboard.gKey.wasPressedThisFrame)
|
||||||
|
Send(connection, 0, 0); // G -> deposit everything to the base stockpile
|
||||||
|
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
while (s_Pending.Count > 0)
|
||||||
|
{
|
||||||
|
var d = s_Pending.Dequeue();
|
||||||
|
Send(connection, d.ItemId, d.Count);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Send(Entity connection, ushort itemId, int count)
|
||||||
|
{
|
||||||
|
var request = EntityManager.CreateEntity();
|
||||||
|
EntityManager.AddComponentData(request, new InventoryDepositRequest { ItemId = itemId, Count = count });
|
||||||
|
EntityManager.AddComponentData(request, new SendRpcCommandRequest { TargetConnection = connection });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9298a5924b4920b4db8ff2f48732a662
|
||||||
@@ -70,6 +70,9 @@ namespace ProjectM.Client
|
|||||||
VisualElement _vignette, _downed;
|
VisualElement _vignette, _downed;
|
||||||
float _prevHp, _flash;
|
float _prevHp, _flash;
|
||||||
bool _haveHp;
|
bool _haveHp;
|
||||||
|
// personal inventory panel (read-only; toggled with I)
|
||||||
|
VisualElement _invPanel, _invList;
|
||||||
|
bool _invOpen;
|
||||||
|
|
||||||
EntityQuery _huskQuery;
|
EntityQuery _huskQuery;
|
||||||
|
|
||||||
@@ -290,6 +293,35 @@ namespace ProjectM.Client
|
|||||||
_vignette.style.display = DisplayStyle.None;
|
_vignette.style.display = DisplayStyle.None;
|
||||||
_downed.style.display = DisplayStyle.None;
|
_downed.style.display = DisplayStyle.None;
|
||||||
}
|
}
|
||||||
|
// ---- Personal inventory (read-only; toggle with I, deposit-all with G via InventoryDepositSendSystem) ----
|
||||||
|
var invKb = UnityEngine.InputSystem.Keyboard.current;
|
||||||
|
if (invKb != null && invKb.iKey.wasPressedThisFrame) _invOpen = !_invOpen;
|
||||||
|
if (_invOpen && found)
|
||||||
|
{
|
||||||
|
EntityManager.CompleteDependencyBeforeRO<InventorySlot>();
|
||||||
|
bool haveItemDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
|
||||||
|
_invPanel.style.display = DisplayStyle.Flex;
|
||||||
|
_invList.Clear();
|
||||||
|
int shown = 0;
|
||||||
|
foreach (var bag in SystemAPI.Query<DynamicBuffer<InventorySlot>>()
|
||||||
|
.WithAll<GhostOwnerIsLocal, PlayerTag>())
|
||||||
|
{
|
||||||
|
for (int i = 0; i < bag.Length; i++)
|
||||||
|
{
|
||||||
|
var slot = bag[i];
|
||||||
|
if (slot.ItemId == 0 || slot.Count <= 0) continue;
|
||||||
|
AddInvRow(ItemName(haveItemDb, itemDb, slot.ItemId), ItemTint(slot.ItemId), slot.Count);
|
||||||
|
shown++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (shown == 0)
|
||||||
|
_invList.Add(HudUi.Text("(empty)", 13, MenuUi.SubCol, TextAnchor.MiddleLeft));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_invPanel.style.display = DisplayStyle.None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- per-frame helpers ----
|
// ---- per-frame helpers ----
|
||||||
@@ -467,6 +499,7 @@ namespace ProjectM.Client
|
|||||||
BuildPaletteRow(root);
|
BuildPaletteRow(root);
|
||||||
BuildHintBar(root);
|
BuildHintBar(root);
|
||||||
BuildDowned(root);
|
BuildDowned(root);
|
||||||
|
BuildInventory(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
void BuildVignette(VisualElement root)
|
void BuildVignette(VisualElement root)
|
||||||
@@ -724,6 +757,67 @@ namespace ProjectM.Client
|
|||||||
root.Add(_downed);
|
root.Add(_downed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void BuildInventory(VisualElement root)
|
||||||
|
{
|
||||||
|
_invPanel = HudUi.Panel(PanelDark);
|
||||||
|
_invPanel.style.position = Position.Absolute;
|
||||||
|
_invPanel.style.right = 40; _invPanel.style.bottom = 40;
|
||||||
|
_invPanel.style.minWidth = 224;
|
||||||
|
_invPanel.style.paddingLeft = 14; _invPanel.style.paddingRight = 14;
|
||||||
|
_invPanel.style.paddingTop = 10; _invPanel.style.paddingBottom = 10;
|
||||||
|
_invPanel.style.alignItems = Align.FlexStart;
|
||||||
|
_invPanel.pickingMode = PickingMode.Ignore;
|
||||||
|
|
||||||
|
var header = HudUi.Display("INVENTORY", 16, AetherCyan, TextAnchor.MiddleLeft);
|
||||||
|
header.style.marginBottom = 6;
|
||||||
|
_invPanel.Add(header);
|
||||||
|
|
||||||
|
_invList = new VisualElement();
|
||||||
|
_invList.pickingMode = PickingMode.Ignore;
|
||||||
|
_invPanel.Add(_invList);
|
||||||
|
|
||||||
|
var hint = HudUi.Text("I close - G deposit all at base", 11, MenuUi.SubCol, TextAnchor.MiddleLeft);
|
||||||
|
hint.style.marginTop = 8;
|
||||||
|
_invPanel.Add(hint);
|
||||||
|
|
||||||
|
_invPanel.style.display = DisplayStyle.None;
|
||||||
|
root.Add(_invPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddInvRow(string name, Color tint, int count)
|
||||||
|
{
|
||||||
|
var row = new VisualElement();
|
||||||
|
row.style.flexDirection = FlexDirection.Row;
|
||||||
|
row.style.justifyContent = Justify.SpaceBetween;
|
||||||
|
row.style.minWidth = 196;
|
||||||
|
row.style.marginTop = 2;
|
||||||
|
row.pickingMode = PickingMode.Ignore;
|
||||||
|
row.Add(HudUi.Text(name, 13, tint, TextAnchor.MiddleLeft));
|
||||||
|
row.Add(HudUi.Display("x" + count, 13, Color.white, TextAnchor.MiddleRight));
|
||||||
|
_invList.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ItemName(bool haveDb, ItemDatabase db, ushort id)
|
||||||
|
{
|
||||||
|
if (haveDb && db.Value.IsCreated)
|
||||||
|
{
|
||||||
|
ref var blob = ref db.Value.Value;
|
||||||
|
if (blob.TryGetItem(id, out var def)) return def.Name.ToString();
|
||||||
|
}
|
||||||
|
if (id == ResourceId.Aether) return "Aether";
|
||||||
|
if (id == ResourceId.Ore) return "Ore";
|
||||||
|
if (id == ResourceId.Biomass) return "Biomass";
|
||||||
|
return "Item " + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color ItemTint(ushort id)
|
||||||
|
{
|
||||||
|
if (id == ResourceId.Aether) return AetherCyan;
|
||||||
|
if (id == ResourceId.Ore) return OreAmber;
|
||||||
|
if (id == ResourceId.Biomass) return BioGreen;
|
||||||
|
return new Color(0.85f, 0.85f, 0.9f);
|
||||||
|
}
|
||||||
|
|
||||||
static Color ResourceTint(byte resId)
|
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,84 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Burst;
|
||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Server
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server-authoritative handler for <see cref="InventoryDepositRequest"/> RPCs: moves items from the
|
||||||
|
/// sender's PERSONAL <see cref="InventorySlot"/> inventory into the shared base stockpile (the global
|
||||||
|
/// <see cref="ResourceLedger"/> the build/upgrade/automation economy spends from). Resolves the sender's
|
||||||
|
/// player (SourceConnection -> NetworkId -> GhostOwner) via the AbilityUpgradeSystem owner-map idiom,
|
||||||
|
/// then withdraws from the player's inventory and deposits into the ledger IN-PLACE (buffer mutation is not
|
||||||
|
/// a structural change). <c>ItemId == 0</c> ("deposit all") is handled BEFORE any per-item withdraw and
|
||||||
|
/// never writes a 0-id row. Resolves the ledger via <c>GetSingletonEntity<ResourceLedger>()</c> then
|
||||||
|
/// <c>GetBuffer<StorageEntry>()</c> — NEVER <c>GetSingleton<StorageEntry></c> (the base
|
||||||
|
/// container owns a second StorageEntry buffer). Plain server SimulationSystemGroup (not predicted, so the
|
||||||
|
/// effect applies exactly once — no rollback double-apply).
|
||||||
|
/// </summary>
|
||||||
|
[BurstCompile]
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
|
public partial struct InventoryDepositSystem : ISystem
|
||||||
|
{
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnCreate(ref SystemState state)
|
||||||
|
{
|
||||||
|
state.RequireForUpdate<ResourceLedger>();
|
||||||
|
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||||
|
.WithAll<InventoryDepositRequest, ReceiveRpcCommandRequest>();
|
||||||
|
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||||
|
}
|
||||||
|
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnUpdate(ref SystemState state)
|
||||||
|
{
|
||||||
|
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||||
|
|
||||||
|
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||||
|
foreach (var (owner, entity) in
|
||||||
|
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
|
||||||
|
playerByConn[owner.ValueRO.NetworkId] = entity;
|
||||||
|
|
||||||
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
|
|
||||||
|
foreach (var (request, receive, requestEntity) in
|
||||||
|
SystemAPI.Query<RefRO<InventoryDepositRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||||
|
{
|
||||||
|
var conn = receive.ValueRO.SourceConnection;
|
||||||
|
if (SystemAPI.HasComponent<NetworkId>(conn)
|
||||||
|
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
|
||||||
|
{
|
||||||
|
var inv = SystemAPI.GetBuffer<InventorySlot>(player);
|
||||||
|
var req = request.ValueRO;
|
||||||
|
|
||||||
|
if (req.ItemId == 0)
|
||||||
|
{
|
||||||
|
// Deposit EVERYTHING: drain each non-empty stack into the ledger, then clear the bag.
|
||||||
|
for (int i = 0; i < inv.Length; i++)
|
||||||
|
{
|
||||||
|
var slot = inv[i];
|
||||||
|
if (slot.ItemId != 0 && slot.Count > 0)
|
||||||
|
StorageMath.Deposit(ledger, slot.ItemId, slot.Count);
|
||||||
|
}
|
||||||
|
inv.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Count <= 0 means "all of that item"; Withdraw clamps to what is available.
|
||||||
|
int want = req.Count <= 0 ? int.MaxValue : req.Count;
|
||||||
|
int moved = InventoryMath.Withdraw(inv, req.ItemId, want);
|
||||||
|
if (moved > 0)
|
||||||
|
StorageMath.Deposit(ledger, req.ItemId, moved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ecb.DestroyEntity(requestEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
ecb.Playback(state.EntityManager);
|
||||||
|
playerByConn.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 25b1bdf13ad8a6d4ca48bef112b98d28
|
||||||
@@ -33,11 +33,16 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
const float k_ProjectileRadius = Tuning.HarvestProjectileRadius;
|
const float k_ProjectileRadius = Tuning.HarvestProjectileRadius;
|
||||||
|
|
||||||
|
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
|
||||||
|
BufferLookup<InventorySlot> m_InvLookup;
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
public void OnCreate(ref SystemState state)
|
public void OnCreate(ref SystemState state)
|
||||||
{
|
{
|
||||||
state.RequireForUpdate<Projectile>();
|
state.RequireForUpdate<Projectile>();
|
||||||
state.RequireForUpdate<ResourceLedger>();
|
state.RequireForUpdate<ResourceLedger>();
|
||||||
|
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(true);
|
||||||
|
m_InvLookup = state.GetBufferLookup<InventorySlot>(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
@@ -46,6 +51,18 @@ namespace ProjectM.Server
|
|||||||
var ledgerEntity = SystemAPI.GetSingletonEntity<ResourceLedger>();
|
var ledgerEntity = SystemAPI.GetSingletonEntity<ResourceLedger>();
|
||||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
|
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
|
||||||
|
|
||||||
|
// Resolve the harvesting player from the projectile's GhostOwner so yield lands in their PERSONAL
|
||||||
|
// inventory. Owner read via a cached lookup (optional); the owner->player map + item catalog are
|
||||||
|
// hoisted out of the per-hit sweep (invariant for the tick).
|
||||||
|
m_GhostOwnerLookup.Update(ref state);
|
||||||
|
m_InvLookup.Update(ref state);
|
||||||
|
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
|
||||||
|
|
||||||
|
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||||
|
foreach (var (owner, playerEntity) in
|
||||||
|
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
|
||||||
|
playerByConn[owner.ValueRO.NetworkId] = playerEntity;
|
||||||
|
|
||||||
// Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set.
|
// Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set.
|
||||||
var tgtEntity = new NativeList<Entity>(Allocator.Temp);
|
var tgtEntity = new NativeList<Entity>(Allocator.Temp);
|
||||||
var tgtPos = new NativeList<float2>(Allocator.Temp);
|
var tgtPos = new NativeList<float2>(Allocator.Temp);
|
||||||
@@ -121,7 +138,30 @@ namespace ProjectM.Server
|
|||||||
// A positive baked yield must always make progress: a raw (int) truncation of a sub-1.0 per-hit
|
// A positive baked yield must always make progress: a raw (int) truncation of a sub-1.0 per-hit
|
||||||
// value would deposit 0 AND never decrement Remaining -> an immortal target that silently eats shots.
|
// value would deposit 0 AND never decrement Remaining -> an immortal target that silently eats shots.
|
||||||
int amount = math.max(1, (int)tgtYieldPerHit[bestIdx]);
|
int amount = math.max(1, (int)tgtYieldPerHit[bestIdx]);
|
||||||
StorageMath.Deposit(ledger, tgtYieldId[bestIdx], amount);
|
byte yieldId = tgtYieldId[bestIdx];
|
||||||
|
|
||||||
|
// Route the yield into the HARVESTING player's PERSONAL inventory. The projectile carries the
|
||||||
|
// firing player's GhostOwner (AbilityFireSystem); the owner is read OPTIONALLY (cached lookup) so
|
||||||
|
// an un-owned projectile (or a test projectile with no GhostOwner) falls through to the ledger.
|
||||||
|
int remainder = amount;
|
||||||
|
if (m_GhostOwnerLookup.HasComponent(projEntity)
|
||||||
|
&& playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var player)
|
||||||
|
&& m_InvLookup.HasBuffer(player))
|
||||||
|
{
|
||||||
|
int stackMax = Tuning.DefaultStackMax;
|
||||||
|
if (haveDb && itemDb.Value.IsCreated)
|
||||||
|
{
|
||||||
|
ref var itemBlob = ref itemDb.Value.Value;
|
||||||
|
if (itemBlob.TryGetItem(yieldId, out var def) && def.StackMax > 0)
|
||||||
|
stackMax = def.StackMax;
|
||||||
|
}
|
||||||
|
var inv = m_InvLookup[player];
|
||||||
|
remainder = InventoryMath.Deposit(inv, yieldId, amount, stackMax, Tuning.InventoryMaxSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unresolvable owner or a full bag: the remainder credits the shared ledger (no-loss valve).
|
||||||
|
if (remainder > 0)
|
||||||
|
StorageMath.Deposit(ledger, yieldId, remainder);
|
||||||
int rem = tgtRemaining[bestIdx] - amount;
|
int rem = tgtRemaining[bestIdx] - amount;
|
||||||
tgtRemaining[bestIdx] = rem;
|
tgtRemaining[bestIdx] = rem;
|
||||||
ecb.DestroyEntity(projEntity);
|
ecb.DestroyEntity(projEntity);
|
||||||
@@ -158,6 +198,7 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
ecb.Playback(state.EntityManager);
|
ecb.Playback(state.EntityManager);
|
||||||
ecb.Dispose();
|
ecb.Dispose();
|
||||||
|
playerByConn.Dispose();
|
||||||
destroyed.Dispose();
|
destroyed.Dispose();
|
||||||
tgtEntity.Dispose();
|
tgtEntity.Dispose();
|
||||||
tgtPos.Dispose();
|
tgtPos.Dispose();
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0d59e1972aec03a4399355f38b87d5be
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Client -> server request to move items from the sender's PERSONAL inventory into the shared base
|
||||||
|
/// stockpile (the global <see cref="ResourceLedger"/> the build/upgrade/automation economy spends from).
|
||||||
|
/// A one-off action, so it is an RPC (not a per-tick predicted input), and the server applies it exactly
|
||||||
|
/// once in the plain SimulationSystemGroup (no rollback double-apply). Payload is plain blittable scalars
|
||||||
|
/// (no entity refs, no enum): the server resolves the sender's player from the RPC's SourceConnection. The
|
||||||
|
/// wire type is UNCONDITIONAL (never #if-gated) so the RpcCollection hash matches across release/dev peers;
|
||||||
|
/// only the send/receive SYSTEMS may be #if-gated.
|
||||||
|
/// </summary>
|
||||||
|
public struct InventoryDepositRequest : IRpcCommand
|
||||||
|
{
|
||||||
|
/// <summary>Item to deposit, or 0 to deposit EVERYTHING the player is carrying. The server branches on
|
||||||
|
/// 0 BEFORE any per-item withdraw and never writes a 0-id row.</summary>
|
||||||
|
public ushort ItemId;
|
||||||
|
|
||||||
|
/// <summary>Quantity to deposit; <= 0 means "all of that item" (ignored when ItemId is 0).</summary>
|
||||||
|
public int Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a815da9a948230e46bc4f7154887613e
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Unity.Entities;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure, deterministic stacking logic for a player's <see cref="InventorySlot"/> buffer (no RNG /
|
||||||
|
/// wall-clock / singleton access, so server and any future prediction agree). Parallels
|
||||||
|
/// <see cref="StorageMath"/> in spirit but does NOT collapse into it: StorageMath is an unbounded
|
||||||
|
/// single-row merge, whereas this enforces a per-item stack cap and a max slot count and supports multiple
|
||||||
|
/// stacks of the same item once a stack fills. DynamicBuffer is a handle, so mutations apply to the
|
||||||
|
/// underlying entity buffer; growing it (buffer.Add) is a resize, NOT a structural change, so it is safe to
|
||||||
|
/// call while iterating a different query. Unit-tested in EditMode via a plain Entities world.
|
||||||
|
/// </summary>
|
||||||
|
public static class InventoryMath
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Add <paramref name="count"/> of <paramref name="itemId"/>: first tops up existing non-full stacks
|
||||||
|
/// of that item, then appends new stacks (each capped at <paramref name="stackMax"/>) while a free slot
|
||||||
|
/// remains (buffer length < <paramref name="maxSlots"/>). Returns the REMAINDER that did not fit
|
||||||
|
/// (0 if everything was deposited). No-op (returns 0) for count <= 0; a positive count of itemId 0
|
||||||
|
/// returns the full count (nothing deposited — never writes a 0-id row). stackMax < 1 = unbounded.
|
||||||
|
/// </summary>
|
||||||
|
public static int Deposit(DynamicBuffer<InventorySlot> buffer, ushort itemId, int count, int stackMax, int maxSlots)
|
||||||
|
{
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
if (itemId == 0) return count;
|
||||||
|
if (stackMax < 1) stackMax = int.MaxValue;
|
||||||
|
|
||||||
|
// Top up existing stacks of this item.
|
||||||
|
for (int i = 0; i < buffer.Length && count > 0; i++)
|
||||||
|
{
|
||||||
|
if (buffer[i].ItemId != itemId) continue;
|
||||||
|
var e = buffer[i];
|
||||||
|
int space = stackMax - e.Count;
|
||||||
|
if (space <= 0) continue;
|
||||||
|
int add = space < count ? space : count;
|
||||||
|
e.Count += add;
|
||||||
|
buffer[i] = e;
|
||||||
|
count -= add;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append new stacks while a slot is free.
|
||||||
|
while (count > 0 && buffer.Length < maxSlots)
|
||||||
|
{
|
||||||
|
int add = stackMax < count ? stackMax : count;
|
||||||
|
buffer.Add(new InventorySlot { ItemId = itemId, Count = add });
|
||||||
|
count -= add;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove up to <paramref name="count"/> of <paramref name="itemId"/> across all its stacks, clamped to
|
||||||
|
/// what is available; drops a stack that reaches zero. Returns the amount actually withdrawn (0 if none).
|
||||||
|
/// Iterates back-to-front so RemoveAt does not skip a stack. No-op for count <= 0 or itemId 0.
|
||||||
|
/// </summary>
|
||||||
|
public static int Withdraw(DynamicBuffer<InventorySlot> buffer, ushort itemId, int count)
|
||||||
|
{
|
||||||
|
if (count <= 0 || itemId == 0) return 0;
|
||||||
|
|
||||||
|
int taken = 0;
|
||||||
|
for (int i = buffer.Length - 1; i >= 0 && count > 0; i--)
|
||||||
|
{
|
||||||
|
if (buffer[i].ItemId != itemId) continue;
|
||||||
|
var e = buffer[i];
|
||||||
|
int t = e.Count < count ? e.Count : count;
|
||||||
|
e.Count -= t;
|
||||||
|
taken += t;
|
||||||
|
count -= t;
|
||||||
|
if (e.Count <= 0)
|
||||||
|
buffer.RemoveAt(i);
|
||||||
|
else
|
||||||
|
buffer[i] = e;
|
||||||
|
}
|
||||||
|
return taken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Total quantity of <paramref name="itemId"/> across all stacks (0 if absent).</summary>
|
||||||
|
public static int CountOf(DynamicBuffer<InventorySlot> buffer, ushort itemId)
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < buffer.Length; i++)
|
||||||
|
if (buffer[i].ItemId == itemId)
|
||||||
|
total += buffer[i].Count;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c8560f6c2e717b943bed78d40ea87404
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One (item, count) row in a player's PERSONAL inventory. The per-player DynamicBuffer of these is the
|
||||||
|
/// server-authoritative source of what that player is carrying. A structural twin of
|
||||||
|
/// <see cref="StatModifier"/>: a [GhostField] buffer with <see cref="SendToOwnerType.All"/> so the owning
|
||||||
|
/// (predicting) client receives its own inventory — without it the owner, being the owner, would not get
|
||||||
|
/// the owner-typed buffer at all and the HUD would read empty. BOTH fields carry [GhostField]; the
|
||||||
|
/// [GhostComponent] attribute alone does NOT auto-replicate fields (an un-annotated field ships as a
|
||||||
|
/// silent zero), so the annotations mirror <see cref="StorageEntry"/> field-for-field.
|
||||||
|
///
|
||||||
|
/// REPLICATION DISCIPLINE — the ONLY writers are server-only: <see cref="ProjectM.Server.ResourceHarvestSystem"/>
|
||||||
|
/// (harvest yield) and the deposit-to-base RPC handler, both in the plain server SimulationSystemGroup. So
|
||||||
|
/// there is no predicted-loop double-apply and the owner never mispredicts its inventory — it is a pure
|
||||||
|
/// server-authored snapshot. NEVER mutate this from a client predicted system (that would reintroduce a
|
||||||
|
/// double-apply / mispredict path). ItemId is the same opaque ushort id space as <see cref="StorageEntry"/>
|
||||||
|
/// and the <see cref="ItemDatabase"/> catalog.
|
||||||
|
///
|
||||||
|
/// NOTE: adding this [GhostField] buffer CHANGES the player ghost serialization hash — the player prefab /
|
||||||
|
/// subscene MUST be re-baked (consistently in both worlds) or the connect handshake desyncs.
|
||||||
|
/// </summary>
|
||||||
|
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
|
||||||
|
[InternalBufferCapacity(24)]
|
||||||
|
public struct InventorySlot : IBufferElementData
|
||||||
|
{
|
||||||
|
/// <summary>Item carried in this slot (0 = empty/unused; aligns with InventoryMath's 0-id no-op).</summary>
|
||||||
|
[GhostField] public ushort ItemId;
|
||||||
|
|
||||||
|
/// <summary>Quantity in this slot (bounded by the item's StackMax when deposited via InventoryMath).</summary>
|
||||||
|
[GhostField] public int Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f151c780df9917d4089b2944f3ffb12d
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Broad item-category ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard that
|
||||||
|
/// already de-Bursted ProjectileClassificationSystem). The category lets systems and UI treat an item
|
||||||
|
/// generically (a resource stacks and is spendable at the base; a tool/weapon is equippable; a
|
||||||
|
/// consumable is used) without a per-id switch. Stored in the ItemDatabase blob's <see cref="ItemDefBlob"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class ItemCategory
|
||||||
|
{
|
||||||
|
/// <summary>Stackable raw material (Aether/Ore/Biomass). Spendable at the base / for crafting.</summary>
|
||||||
|
public const byte Resource = 0;
|
||||||
|
|
||||||
|
/// <summary>Gathering tool (axe/pickaxe). Equippable; gates + scales harvesting (Phase 2).</summary>
|
||||||
|
public const byte Tool = 1;
|
||||||
|
|
||||||
|
/// <summary>Weapon. Equipping it grants its ability + stat modifiers (Phase 1).</summary>
|
||||||
|
public const byte Weapon = 2;
|
||||||
|
|
||||||
|
/// <summary>Wearable gear (armour/trinket). Equipping it grants stat modifiers (Phase 1).</summary>
|
||||||
|
public const byte Gear = 3;
|
||||||
|
|
||||||
|
/// <summary>One-shot consumable (potion/charge). Used from the inventory (later phase).</summary>
|
||||||
|
public const byte Consumable = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fa1718754184d2a418b1099ef7e3ae34
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Unity.Entities;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton handle to the baked item-definition database (config, not replicated — baked identically
|
||||||
|
/// into both worlds from the gameplay subscene, exactly like <see cref="AbilityDatabase"/>). A distinct
|
||||||
|
/// component type, so <c>GetSingleton<ItemDatabase>()</c> resolves independently of the ability
|
||||||
|
/// database (singleton-ness is per type). Optional at runtime: consumers that read it use
|
||||||
|
/// <c>TryGetSingleton</c> and fall back to defaults (e.g. <c>Tuning.DefaultStackMax</c>) so the sim still
|
||||||
|
/// runs before the catalog is authored.
|
||||||
|
/// </summary>
|
||||||
|
public struct ItemDatabase : IComponentData
|
||||||
|
{
|
||||||
|
public BlobAssetReference<ItemDatabaseBlob> Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6b355888562be7349b8754168375db9b
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Entities;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One authored item definition, baked immutable into the <see cref="ItemDatabase"/> blob. This is the
|
||||||
|
/// single source of truth for everything an item IS — resources, tools, weapons, gear, consumables — so
|
||||||
|
/// adding game content is an authoring row + re-bake, with no code change. Id space is the SAME
|
||||||
|
/// <c>ushort</c> space as <see cref="StorageEntry.ItemId"/> / <see cref="InventorySlot.ItemId"/>, and it
|
||||||
|
/// SUBSUMES the low <see cref="ResourceId"/> byte ids (Aether=1/Ore=2/Biomass=3) — a resource is just a
|
||||||
|
/// low-id item of <see cref="ItemCategory.Resource"/>. KEEP ids 1-3 stable for the existing resources and
|
||||||
|
/// reserve new item ids > 3; 0 = none. Entity/prefab refs do NOT live here (blobs don't remap entity
|
||||||
|
/// refs) — a future companion buffer carries those, exactly like AbilityPrefabElement.
|
||||||
|
///
|
||||||
|
/// The blob is config (baked identically into both worlds, NOT replicated, NOT in SaveData), so growing
|
||||||
|
/// this struct later (a granted-ability id, a StatModifier-spec array, a slot id for Phase 1/2/3) is a
|
||||||
|
/// pure re-bake with zero migration: no SaveData version bump, no ghost-hash change, no desync. <see cref="Tier"/>
|
||||||
|
/// is baked NOW because the project's progression axis is gear tiers, so Phase 2/3 tier gating is a
|
||||||
|
/// content-only edit.
|
||||||
|
/// </summary>
|
||||||
|
public struct ItemDefBlob
|
||||||
|
{
|
||||||
|
/// <summary>Stable item id (ushort; 1-3 reserved for the existing resources, keep stable for saves).</summary>
|
||||||
|
public ushort ItemId;
|
||||||
|
|
||||||
|
/// <summary>Broad category (see <see cref="ItemCategory"/>), stored as a byte.</summary>
|
||||||
|
public byte Category;
|
||||||
|
|
||||||
|
/// <summary>Progression tier (0 = base). Higher-tier tools harvest higher-tier nodes / hit harder (Phase 2/3).</summary>
|
||||||
|
public byte Tier;
|
||||||
|
|
||||||
|
/// <summary>Max units that stack in a single inventory slot (1 for non-stacking equipment).</summary>
|
||||||
|
public int StackMax;
|
||||||
|
|
||||||
|
/// <summary>Designer-facing display name (shown in the HUD inventory panel).</summary>
|
||||||
|
public FixedString64Bytes Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable designer-authored item database, baked from ScriptableObjects to a blob asset and shared by
|
||||||
|
/// every entity (Burst-fast, zero per-instance cost). Looked up by stable <see cref="ItemDefBlob.ItemId"/>
|
||||||
|
/// — ID-KEYED, never by array index, so inserting a new item never renumbers existing ids.
|
||||||
|
///
|
||||||
|
/// NOTE: the lookup is intentionally NOT a 'readonly' method. A readonly struct method forces a defensive
|
||||||
|
/// copy of a field when calling a non-readonly member on it; copying a BlobArray breaks its relative-offset
|
||||||
|
/// pointer, so the array would read as empty. A plain (non-readonly) method accesses the BlobArray in place.
|
||||||
|
/// Always reach this through 'ref blob.Value' (mirrors <see cref="AbilityDatabaseBlob"/>).
|
||||||
|
/// </summary>
|
||||||
|
public struct ItemDatabaseBlob
|
||||||
|
{
|
||||||
|
public BlobArray<ItemDefBlob> Items;
|
||||||
|
|
||||||
|
/// <summary>Linear lookup by item id (the array is tiny). Returns false if not present.</summary>
|
||||||
|
public bool TryGetItem(ushort id, out ItemDefBlob def)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < Items.Length; i++)
|
||||||
|
{
|
||||||
|
if (Items[i].ItemId == id)
|
||||||
|
{
|
||||||
|
def = Items[i];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
def = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0ae0cc6bfb8578c42bff8e300078cf2d
|
||||||
@@ -56,5 +56,13 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>Max production cycles a single machine awards in one process (bounds within-session
|
/// <summary>Max production cycles a single machine awards in one process (bounds within-session
|
||||||
/// catch-up after any skipped ticks; restore re-seats the baseline so this never reflects wall-clock).</summary>
|
/// catch-up after any skipped ticks; restore re-seats the baseline so this never reflects wall-clock).</summary>
|
||||||
public const int MaxProductionCatchup = 600;
|
public const int MaxProductionCatchup = 600;
|
||||||
|
|
||||||
|
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
|
||||||
|
|
||||||
|
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>
|
||||||
|
public const int InventoryMaxSlots = 24;
|
||||||
|
|
||||||
|
/// <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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1449,6 +1449,55 @@ BoxCollider:
|
|||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_Size: {x: 3, y: 2.5, z: 3}
|
m_Size: {x: 3, y: 2.5, z: 3}
|
||||||
m_Center: {x: 0, y: 1.25, z: 0}
|
m_Center: {x: 0, y: 1.25, z: 0}
|
||||||
|
--- !u!1 &722706768
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 722706770}
|
||||||
|
- component: {fileID: 722706769}
|
||||||
|
m_Layer: 0
|
||||||
|
m_Name: ItemDatabase
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!114 &722706769
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 722706768}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 5ee44dc3bc9f3164592195d4068be8d1, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDatabaseAuthoring
|
||||||
|
Items:
|
||||||
|
- {fileID: 11400000, guid: 938a882b6fbd50d4c9a051ddabc5829b, type: 2}
|
||||||
|
- {fileID: 11400000, guid: a2a0e6d3ae0218d458d2b5305891ce89, type: 2}
|
||||||
|
- {fileID: 11400000, guid: 4ed399a329eb6d847921aef05bde213d, type: 2}
|
||||||
|
- {fileID: 11400000, guid: 31ec40cfc6e81ef4ab9bbce57a40621e, type: 2}
|
||||||
|
--- !u!4 &722706770
|
||||||
|
Transform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 722706768}
|
||||||
|
serializedVersion: 2
|
||||||
|
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||||
|
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||||
|
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
|
m_ConstrainProportionsScale: 0
|
||||||
|
m_Children: []
|
||||||
|
m_Father: {fileID: 0}
|
||||||
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
--- !u!1 &842917524
|
--- !u!1 &842917524
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -3685,3 +3734,4 @@ SceneRoots:
|
|||||||
- {fileID: 1698085403}
|
- {fileID: 1698085403}
|
||||||
- {fileID: 450973138}
|
- {fileID: 450973138}
|
||||||
- {fileID: 1268920429}
|
- {fileID: 1268920429}
|
||||||
|
- {fileID: 722706770}
|
||||||
|
|||||||
Reference in New Issue
Block a user