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:
2026-07-08 14:00:55 -07:00
parent 55e98a9275
commit 6379f5d897
16 changed files with 1494 additions and 933 deletions
@@ -0,0 +1,164 @@
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 choice-of-3 boon modal (RoomReward) — 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 55) so it composes into the
/// same UITK panel + event dispatcher as the HUD (50) / markers (48) / onboarding (60). Reads the local player's
/// replicated <see cref="BoonOffer"/> + the <see cref="BoonCatalog"/> blob; card clicks enqueue through
/// <see cref="BoonSendSystem.PickBoon"/>. Built lazily on first show.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class BoonModalHudSystem : SystemBase
{
GameObject _go;
UIDocument _doc;
bool _built;
VisualElement _boonModal, _boonCardRow;
int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for
bool _boonModalBuilt;
protected override void OnStartRunning()
{
if (_go != null) return;
MenuUi.EnsureEventSystem();
_go = new GameObject("~HUDBoonModal");
_doc = _go.AddComponent<UIDocument>();
_doc.panelSettings = MenuUi.LoadPanelSettings();
_doc.sortingOrder = 55;
}
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);
BoonOffer localOffer = default;
bool hasOffer = false;
foreach (var off in SystemAPI.Query<RefRO<BoonOffer>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localOffer = off.ValueRO;
hasOffer = true;
break;
}
BlobAssetReference<BoonCatalogBlob> boonPool = default;
if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat))
boonPool = bcat.Value;
// Lifecycle gate (post-impl review): even a stale replicated Pending never shows the modal outside
// the reward window.
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1
&& haveRun && runInfo.Lifecycle == RunLifecycle.RoomReward, boonPool);
}
void UpdateBoonModal(BoonOffer offer, bool show, BlobAssetReference<BoonCatalogBlob> pool)
{
if (!show || !pool.IsCreated)
{
if (_boonModal != null) _boonModal.style.display = DisplayStyle.None;
_boonShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_boonModalBuilt)
{
BuildBoonModal(root);
_boonModalBuilt = true;
}
// Rebuild the three cards only when the offer actually changes (a new room's deal).
// Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive
// rooms and leave stale card labels. +1 keeps 0 as the hidden/reset sentinel.
int sig = 1 + (offer.Option0 | (offer.Option1 << 8) | (offer.Option2 << 16));
if (_boonShownFor != sig)
{
_boonCardRow.Clear();
ref var defs = ref pool.Value;
for (byte k = 0; k < 3; k++)
{
byte id = k == 2 ? offer.Option2 : k == 1 ? offer.Option1 : offer.Option0;
int idx = BoonMath.FindDef(ref defs, id);
string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id);
string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : "";
byte weight = idx >= 0 ? defs.Defs[idx].Weight : (byte)100;
byte pick = k; // capture a COPY into the closure, never the loop variable
var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick));
card.style.width = 200;
card.style.height = StyleKeyword.Auto; // long descs grow the card
card.style.minHeight = 96;
// Rarity from the draw weight (100 common / 60 uncommon / 30 rare / 10 epic).
var rare = weight <= 10 ? new Color(1f, 0.82f, 0.30f)
: weight <= 30 ? new Color(0.65f, 0.50f, 1f)
: weight <= 60 ? new Color(0.45f, 0.95f, 0.55f)
: new Color(1f, 1f, 1f, 0.30f);
MenuUi.Border(card, rare, weight <= 30 ? 2.5f : 1.5f);
card.style.marginLeft = 8;
card.style.marginRight = 8;
card.style.whiteSpace = WhiteSpace.Normal;
_boonCardRow.Add(card);
}
_boonShownFor = sig;
}
_boonModal.style.display = DisplayStyle.Flex;
}
void BuildBoonModal(VisualElement root)
{
_boonModal = new VisualElement { pickingMode = PickingMode.Ignore };
_boonModal.style.position = Position.Absolute;
_boonModal.style.left = 0; _boonModal.style.right = 0;
_boonModal.style.top = 0; _boonModal.style.bottom = 0;
_boonModal.style.alignItems = Align.Center;
_boonModal.style.justifyContent = Justify.Center;
_boonModal.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.96f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 18; box.style.paddingRight = 18;
box.style.paddingTop = 14; box.style.paddingBottom = 16;
box.style.alignItems = Align.Center;
var title = new Label("ROOM CLEARED — CHOOSE A BOON");
title.style.color = new Color(0.6f, 1f, 0.7f);
title.style.fontSize = 18;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
title.style.marginBottom = 12;
box.Add(title);
_boonCardRow = new VisualElement();
_boonCardRow.style.flexDirection = FlexDirection.Row;
box.Add(_boonCardRow);
_boonModal.Add(box);
root.Add(_boonModal);
}
}
}