Hygiene B5: split HudSystem + CombatFeedbackSystem god-objects
Extract 7 sibling PresentationSystemGroup systems (client-only, observe-only), faithfully relocating methods+fields so behavior is preserved by construction: - HudSystem (2129->1588L): BoonModalHudSystem, RouteMapHudSystem, MetaShopHudSystem, ClassPrepPortalHudSystem — each owns its own runtime UIDocument (MenuUi.LoadPanelSettings + own sortingOrder + EnsureEventSystem), the proven EnemyMarkerSystem/OnboardingSystem pattern; no shared root, no new static bridge. - CombatFeedbackSystem (1300->914L): RoomPortalBeaconSystem, EnemyHealthBarSystem, EnemyDangerTelegraphSystem — each owns its FX-root + mats (via FeedbackFx), self-queries enemies + self-detects its edge (health-bar LastHp; danger _prevWindup), prunes its caches each frame. Verified: compiles clean (0 errors), 466/466 EditMode tests pass, Play world-creation clean (no ComponentSystemSorter cycle, no OnCreate exception, 0 console errors). NOTE: the final VISUAL smoke (panels appear at the right lifecycle; enemy health bars / danger telegraphs / portal beacon render; buttons live) needs a FOCUSED Play pass — the play-mode transition throttles while Unity is unfocused, so I could not drive live frames headlessly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
using Unity.Mathematics;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// The Staging-only permanent-upgrade shop (meta shop) — extracted from <see cref="HudSystem"/> into its own
|
||||
/// client-only, observe-only presentation <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>.
|
||||
/// Owns its own runtime UIDocument sharing <see cref="MenuUi.LoadPanelSettings"/> (sortingOrder 51). Recomputes
|
||||
/// its inputs locally: the local class from the replicated <see cref="AbilityRef"/>
|
||||
/// (<see cref="ClassTraits.ClassForAbility"/>), Aether from the <see cref="ResourceLedger"/> buffer, siege from
|
||||
/// <see cref="CycleState"/>, and the Staging gate from <see cref="RunInfo"/>. Row clicks enqueue through
|
||||
/// <see cref="MetaSpendSendSystem.RequestPurchase"/> — the server re-validates everything.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class MetaShopHudSystem : SystemBase
|
||||
{
|
||||
GameObject _go;
|
||||
UIDocument _doc;
|
||||
bool _built;
|
||||
|
||||
VisualElement _metaPanel, _metaRowsHost;
|
||||
Label _metaShopTitle;
|
||||
bool _metaShopBuilt;
|
||||
int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
if (_go != null) return;
|
||||
MenuUi.EnsureEventSystem();
|
||||
_go = new GameObject("~HUDMetaShop");
|
||||
_doc = _go.AddComponent<UIDocument>();
|
||||
_doc.panelSettings = MenuUi.LoadPanelSettings();
|
||||
_doc.sortingOrder = 51;
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_go != null) Object.Destroy(_go);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (_doc == null) return;
|
||||
var root = _doc.rootVisualElement;
|
||||
if (root == null) return; // panel not initialised yet (next frame)
|
||||
if (!_built)
|
||||
{
|
||||
root.style.position = Position.Absolute;
|
||||
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
|
||||
root.pickingMode = PickingMode.Ignore; // never eat game-world clicks
|
||||
_built = true;
|
||||
}
|
||||
|
||||
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
|
||||
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
||||
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
|
||||
|
||||
// Aether from the ledger (the sole meta-shop currency; last entry wins, matching the core loop).
|
||||
int aether = 0;
|
||||
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
|
||||
{
|
||||
var buf = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
|
||||
for (int i = 0; i < buf.Length; i++)
|
||||
if (buf[i].ItemId == ResourceId.Aether) aether = buf[i].Count;
|
||||
}
|
||||
|
||||
// Local class derives from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is
|
||||
// server-only); tiers from the replicated MetaTierState record on the director ghost.
|
||||
byte localClass = ClassTraits.WarriorClass;
|
||||
bool haveLocalPlayer = false;
|
||||
foreach (var ar in SystemAPI.Query<RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||
{
|
||||
localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id);
|
||||
haveLocalPlayer = true;
|
||||
break;
|
||||
}
|
||||
|
||||
bool metaShow = false;
|
||||
BlobAssetReference<MetaUpgradeCatalogBlob> metaPool = default;
|
||||
DynamicBuffer<MetaTierState> metaRecord = default;
|
||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege
|
||||
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
|
||||
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true))
|
||||
{
|
||||
metaPool = metaCat.Value;
|
||||
metaShow = true;
|
||||
}
|
||||
UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord);
|
||||
}
|
||||
|
||||
void UpdateMetaShop(bool show, byte classId, int aether,
|
||||
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
|
||||
{
|
||||
if (!show)
|
||||
{
|
||||
if (_metaPanel != null) _metaPanel.style.display = DisplayStyle.None;
|
||||
_metaShownFor = 0;
|
||||
return;
|
||||
}
|
||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||
if (root == null) return;
|
||||
if (!_metaShopBuilt)
|
||||
{
|
||||
BuildMetaShop(root);
|
||||
_metaShopBuilt = true;
|
||||
}
|
||||
|
||||
// Rebuild the rows only when class / owned tiers / affordability actually change (Staging-only, <=8 rows).
|
||||
int sig = classId * 131 ^ aether * 31;
|
||||
for (int i = 0; i < record.Length; i++)
|
||||
sig ^= (record[i].ClassId * 7 + record[i].UpgradeId * 13 + record[i].Tier) * (i + 3);
|
||||
if (sig == 0) sig = 1;
|
||||
if (_metaShownFor != sig)
|
||||
{
|
||||
_metaRowsHost.Clear();
|
||||
_metaShopTitle.text = (classId == ClassTraits.RangerClass ? "RANGER" : "WARRIOR")
|
||||
+ " PERMANENT UPGRADES - AETHER " + aether;
|
||||
ref var defs = ref pool.Value;
|
||||
byte classBit = BoonMath.MaskFor(classId);
|
||||
for (int d = 0; d < defs.Defs.Length; d++)
|
||||
{
|
||||
if ((defs.Defs[d].ClassMask & classBit) == 0) continue;
|
||||
byte id = defs.Defs[d].Id;
|
||||
byte owned = MetaMath.TierOf(record, classId, id);
|
||||
if (owned > defs.Defs[d].MaxTier) owned = defs.Defs[d].MaxTier; // D-F5 display clamp (seed AND spend AND shop)
|
||||
bool maxed = owned >= defs.Defs[d].MaxTier;
|
||||
int cost = MetaMath.CostForTier(in defs.Defs[d], owned);
|
||||
string label = defs.Defs[d].Name.ToString()
|
||||
+ (maxed ? " MAXED" : " - " + cost + " Aether")
|
||||
+ "\n" + defs.Defs[d].Desc.ToString();
|
||||
byte buyId = id; // closure copy, never the loop variable
|
||||
var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId));
|
||||
row.style.width = 290;
|
||||
row.style.height = StyleKeyword.Auto; // two-line labels must grow the row (overlap fix)
|
||||
row.style.paddingTop = 6; row.style.paddingBottom = 6;
|
||||
row.style.marginBottom = 4;
|
||||
row.style.whiteSpace = WhiteSpace.Normal;
|
||||
row.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||
row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway
|
||||
// Owned-tier pips (replaces the "[2/5]" text — reads at a glance).
|
||||
var pipRow = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
pipRow.style.flexDirection = FlexDirection.Row;
|
||||
pipRow.style.marginTop = 3;
|
||||
for (int p = 0; p < defs.Defs[d].MaxTier; p++)
|
||||
{
|
||||
var tp = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
tp.style.width = 9; tp.style.height = 9;
|
||||
tp.style.marginRight = 3;
|
||||
MenuUi.Round(tp, 4.5f);
|
||||
tp.style.backgroundColor = p < owned ? MenuUi.Accent : new Color(1f, 1f, 1f, 0.14f);
|
||||
pipRow.Add(tp);
|
||||
}
|
||||
row.Add(pipRow);
|
||||
_metaRowsHost.Add(row);
|
||||
}
|
||||
_metaShownFor = sig;
|
||||
}
|
||||
_metaPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void BuildMetaShop(VisualElement root)
|
||||
{
|
||||
_metaPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_metaPanel.style.position = Position.Absolute;
|
||||
_metaPanel.style.right = 12;
|
||||
_metaPanel.style.top = Length.Percent(22);
|
||||
_metaPanel.style.alignItems = Align.FlexEnd;
|
||||
_metaPanel.style.display = DisplayStyle.None;
|
||||
|
||||
var box = new VisualElement();
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
|
||||
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
|
||||
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
|
||||
box.style.paddingLeft = 12; box.style.paddingRight = 12;
|
||||
box.style.paddingTop = 10; box.style.paddingBottom = 10;
|
||||
|
||||
_metaShopTitle = new Label("PERMANENT UPGRADES");
|
||||
_metaShopTitle.style.color = MenuUi.Accent;
|
||||
_metaShopTitle.style.fontSize = 14;
|
||||
_metaShopTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
_metaShopTitle.style.marginBottom = 8;
|
||||
box.Add(_metaShopTitle);
|
||||
|
||||
_metaRowsHost = new VisualElement();
|
||||
box.Add(_metaRowsHost);
|
||||
|
||||
_metaPanel.Add(box);
|
||||
root.Add(_metaPanel);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user