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:
@@ -72,20 +72,6 @@ namespace ProjectM.Client
|
||||
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
|
||||
VisualElement _runBanner;
|
||||
Label _runBannerText, _runBannerSub;
|
||||
// Step 14 (expedition redesign): the choice-of-3 boon modal + the route-choice panel. Both are
|
||||
// observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks
|
||||
// enqueue through the client send-systems' statics. Built lazily on first show.
|
||||
VisualElement _boonModal, _boonCardRow;
|
||||
VisualElement _routePanel;
|
||||
Label _routeTitle;
|
||||
int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for
|
||||
bool _boonModalBuilt, _routePanelBuilt;
|
||||
// Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether;
|
||||
// row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything).
|
||||
VisualElement _metaPanel, _metaRowsHost;
|
||||
Label _metaShopTitle;
|
||||
bool _metaShopBuilt;
|
||||
int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for
|
||||
// Demo polish: the clickable READY panel (Staging/Launching) + the drawn branching route map
|
||||
// (RouteSelect) — the map is regenerated client-side from RunInfo.RunSeed for DISPLAY only; the
|
||||
// clickable next-layer nodes bind to the authoritative RouteOpt* bytes (never the regen).
|
||||
@@ -94,10 +80,6 @@ namespace ProjectM.Client
|
||||
Label _readyTitle;
|
||||
bool _readyPanelBuilt;
|
||||
int _readyShownFor; // (ready, total, localReady, secs, launching) rebuild signature
|
||||
VisualElement _routeMapHost; // node circles + Painter2D edges
|
||||
int _routeMapSig; // (seed, room, col, options) signature the map was drawn for
|
||||
readonly List<int> _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed
|
||||
uint _routeVisitedSeed;
|
||||
// Demo polish round 2: boss presence bar, run-depth dots, outcome flash.
|
||||
VisualElement _bossPanel, _bossFill;
|
||||
Label _bossText;
|
||||
@@ -319,36 +301,6 @@ namespace ProjectM.Client
|
||||
}
|
||||
|
||||
|
||||
// ---- Step 14: the choice-of-3 boon modal + the route-choice panel (observe replicated state; the
|
||||
// card/button clicks enqueue through the client send-systems' statics) ----
|
||||
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);
|
||||
UpdateRoutePanel(haveRun ? runInfo : default);
|
||||
|
||||
// Client-local path trace for the route map (nodeIds visited this run; display-only).
|
||||
if (haveRun && runInfo.RunSeed != _routeVisitedSeed)
|
||||
{
|
||||
_routeVisited.Clear();
|
||||
_routeVisitedSeed = runInfo.RunSeed;
|
||||
}
|
||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom)
|
||||
{
|
||||
int visitedNode = RunMap.NodeId(runInfo.CurrentRoom, runInfo.CurrentCol);
|
||||
if (_routeVisited.Count == 0 || _routeVisited[^1] != visitedNode) _routeVisited.Add(visitedNode);
|
||||
}
|
||||
|
||||
// The clickable READY panel (Staging/Launching, hidden once the outcome latched — the banner owns
|
||||
// the screen then). Counts are the replicated send-to-all PlayerReady flags.
|
||||
@@ -452,31 +404,6 @@ namespace ProjectM.Client
|
||||
_bioNum.text = bio.ToString();
|
||||
_chargeNum.text = charge.ToString();
|
||||
|
||||
// ---- Step 14 (meta shop): Staging-only permanent-upgrade shop for the LOCAL class. 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; Aether from the ledger read above. ----
|
||||
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);
|
||||
UpdateClassPanel(metaShow, localClass); // DR-046: base class pick (Staging)
|
||||
UpdatePrepPanel(metaShow, ore, bio, aether); // DR-046: base prep loadout (Staging)
|
||||
UpdatePortalPrompt(haveRun ? runInfo : default, haveRun); // DR-046: room-exit portal prompt (RoomExplore)
|
||||
|
||||
// DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const).
|
||||
// (Step 11: upgrade-button affordability tint retired with the button.)
|
||||
@@ -1441,269 +1368,27 @@ namespace ProjectM.Client
|
||||
: roomType == RoomTypeId.Elite ? "[ELITE]"
|
||||
: roomType == RoomTypeId.Reward ? "[REWARD]" : "[COMBAT]";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind
|
||||
// to the authoritative RouteOpt* bytes) ----
|
||||
|
||||
const float MapStrideX = 58f, MapStrideY = 46f, MapNodeSize = 34f, MapPad = 14f;
|
||||
|
||||
static Vector2 MapNodePos(int layer, int col, byte layerWidth)
|
||||
{
|
||||
float x = MapPad + layer * MapStrideX;
|
||||
float y = MapPad + MapStrideY + (col - (layerWidth - 1) * 0.5f) * MapStrideY;
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
static string RoomGlyph(byte t) => t == RoomTypeId.Boss ? "B"
|
||||
: t == RoomTypeId.Elite ? "E" : t == RoomTypeId.Reward ? "R" : "C";
|
||||
|
||||
static Color RoomColor(byte t) => t == RoomTypeId.Boss ? new Color(0.92f, 0.28f, 0.22f)
|
||||
: t == RoomTypeId.Elite ? new Color(0.80f, 0.45f, 1f)
|
||||
: t == RoomTypeId.Reward ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 0.72f, 0.35f);
|
||||
|
||||
void UpdateRoutePanel(RunInfo runInfo)
|
||||
{
|
||||
// Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion).
|
||||
bool show = runInfo.Lifecycle == RunLifecycle.RouteSelect && runInfo.RouteOptionCount > 0;
|
||||
if (!show)
|
||||
{
|
||||
if (_routePanel != null) _routePanel.style.display = DisplayStyle.None;
|
||||
_routeMapSig = 0;
|
||||
return;
|
||||
}
|
||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||
if (root == null) return;
|
||||
if (!_routePanelBuilt)
|
||||
{
|
||||
BuildRoutePanel(root);
|
||||
_routePanelBuilt = true;
|
||||
}
|
||||
|
||||
int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31
|
||||
^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16)
|
||||
^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20);
|
||||
if (sig == 0) sig = 1;
|
||||
if (_routeMapSig != sig)
|
||||
{
|
||||
RebuildRouteMap(runInfo);
|
||||
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
|
||||
_routeMapSig = sig;
|
||||
}
|
||||
_routePanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void BuildRoutePanel(VisualElement root)
|
||||
{
|
||||
_routePanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_routePanel.style.position = Position.Absolute;
|
||||
_routePanel.style.left = 0; _routePanel.style.right = 0;
|
||||
_routePanel.style.top = 0; _routePanel.style.bottom = 0;
|
||||
_routePanel.style.alignItems = Align.Center;
|
||||
_routePanel.style.justifyContent = Justify.Center;
|
||||
_routePanel.style.display = DisplayStyle.None;
|
||||
|
||||
var box = new VisualElement { pickingMode = PickingMode.Position }; // swallow world clicks under the map
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f);
|
||||
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 = 12; box.style.paddingBottom = 12;
|
||||
box.style.alignItems = Align.Center;
|
||||
|
||||
_routeTitle = new Label("CHOOSE YOUR PATH");
|
||||
_routeTitle.style.color = new Color(0.55f, 0.85f, 1f);
|
||||
_routeTitle.style.fontSize = 16;
|
||||
_routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
_routeTitle.style.marginBottom = 10;
|
||||
box.Add(_routeTitle);
|
||||
|
||||
_routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_routeMapHost.style.position = Position.Relative;
|
||||
box.Add(_routeMapHost);
|
||||
|
||||
var cap = HudUi.Text("your path is lit — click a highlighted room to commit the party", 13,
|
||||
MenuUi.SubCol, TextAnchor.MiddleCenter);
|
||||
cap.style.marginTop = 10;
|
||||
box.Add(cap);
|
||||
|
||||
_routePanel.Add(box);
|
||||
root.Add(_routePanel);
|
||||
}
|
||||
|
||||
void RebuildRouteMap(RunInfo runInfo)
|
||||
{
|
||||
_routeMapHost.Clear();
|
||||
var map = RunMapMath.Generate(runInfo.RunSeed);
|
||||
_routeMapHost.style.width = MapPad * 2f + (map.LayerCount - 1) * MapStrideX + MapNodeSize;
|
||||
_routeMapHost.style.height = MapPad * 2f + 2f * MapStrideY + MapNodeSize;
|
||||
|
||||
// Edges under the nodes (Painter2D); walked segments glow, the rest are faint.
|
||||
var edges = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
edges.style.position = Position.Absolute;
|
||||
edges.style.left = 0; edges.style.top = 0; edges.style.right = 0; edges.style.bottom = 0;
|
||||
var mapCopy = map;
|
||||
var visited = new List<int>(_routeVisited);
|
||||
edges.generateVisualContent += ctx =>
|
||||
{
|
||||
var p = ctx.painter2D;
|
||||
p.lineWidth = 2f;
|
||||
var c = new Vector2(MapNodeSize * 0.5f, MapNodeSize * 0.5f);
|
||||
for (int layer = 0; layer < mapCopy.LayerCount - 1; layer++)
|
||||
for (int col = 0; col < mapCopy.LayerWidths[layer]; col++)
|
||||
{
|
||||
var node = mapCopy.Node(layer, col);
|
||||
if (node.NextMask == 0) continue;
|
||||
var a = MapNodePos(layer, col, mapCopy.LayerWidths[layer]);
|
||||
for (int j = 0; j < mapCopy.LayerWidths[layer + 1]; j++)
|
||||
{
|
||||
if ((node.NextMask & (1 << j)) == 0) continue;
|
||||
var b = MapNodePos(layer + 1, j, mapCopy.LayerWidths[layer + 1]);
|
||||
bool walked = visited.Contains(RunMap.NodeId(layer, col))
|
||||
&& visited.Contains(RunMap.NodeId(layer + 1, j));
|
||||
p.strokeColor = walked ? new Color(0.55f, 0.85f, 1f, 0.9f) : new Color(1f, 1f, 1f, 0.16f);
|
||||
p.BeginPath();
|
||||
p.MoveTo(a + c);
|
||||
p.LineTo(b + c);
|
||||
p.Stroke();
|
||||
}
|
||||
}
|
||||
};
|
||||
_routeMapHost.Add(edges);
|
||||
|
||||
int nextLayer = runInfo.CurrentRoom + 1;
|
||||
for (int layer = 0; layer < map.LayerCount; layer++)
|
||||
for (int col = 0; col < map.LayerWidths[layer]; col++)
|
||||
{
|
||||
var node = map.Node(layer, col);
|
||||
bool isCurrent = layer == runInfo.CurrentRoom && col == runInfo.CurrentCol;
|
||||
bool wasVisited = _routeVisited.Contains(RunMap.NodeId(layer, col));
|
||||
byte opt = 255;
|
||||
if (layer == nextLayer)
|
||||
{
|
||||
if (runInfo.RouteOptionCount > 0 && col == runInfo.RouteOpt0Col) opt = 0;
|
||||
else if (runInfo.RouteOptionCount > 1 && col == runInfo.RouteOpt1Col) opt = 1;
|
||||
else if (runInfo.RouteOptionCount > 2 && col == runInfo.RouteOpt2Col) opt = 2;
|
||||
}
|
||||
_routeMapHost.Add(MakeMapNode(node.RoomType,
|
||||
MapNodePos(layer, col, map.LayerWidths[layer]), isCurrent, wasVisited, opt, layer <= runInfo.CurrentRoom));
|
||||
}
|
||||
}
|
||||
|
||||
VisualElement MakeMapNode(byte roomType, Vector2 pos, bool isCurrent, bool visited, byte optionIndex, bool past)
|
||||
{
|
||||
bool clickable = optionIndex != 255;
|
||||
var n = new VisualElement { pickingMode = clickable ? PickingMode.Position : PickingMode.Ignore };
|
||||
n.style.position = Position.Absolute;
|
||||
n.style.left = pos.x; n.style.top = pos.y;
|
||||
n.style.width = MapNodeSize; n.style.height = MapNodeSize;
|
||||
MenuUi.Round(n, MapNodeSize * 0.5f);
|
||||
var c = RoomColor(roomType);
|
||||
float bgA = clickable ? 0.95f : visited || isCurrent ? 0.85f : past ? 0.20f : 0.40f;
|
||||
var restBg = new Color(c.r * 0.35f, c.g * 0.35f, c.b * 0.35f, bgA);
|
||||
n.style.backgroundColor = restBg;
|
||||
MenuUi.Border(n, isCurrent ? new Color(0.55f, 0.85f, 1f) : clickable ? c : new Color(1f, 1f, 1f, 0.18f),
|
||||
isCurrent || clickable ? 2.5f : 1.2f);
|
||||
var lbl = new Label(RoomGlyph(roomType)) { pickingMode = PickingMode.Ignore };
|
||||
lbl.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
lbl.style.flexGrow = 1;
|
||||
lbl.style.color = clickable || visited || isCurrent ? c : new Color(1f, 1f, 1f, 0.35f);
|
||||
lbl.style.fontSize = 15;
|
||||
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
n.Add(lbl);
|
||||
if (clickable)
|
||||
{
|
||||
byte pick = optionIndex; // closure copy, never the loop variable
|
||||
n.RegisterCallback<ClickEvent>(_ => RouteSendSystem.PickRoute(pick));
|
||||
n.RegisterCallback<MouseEnterEvent>(_ =>
|
||||
n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f));
|
||||
n.RegisterCallback<MouseLeaveEvent>(_ => n.style.backgroundColor = restBg);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ----
|
||||
|
||||
@@ -1874,255 +1559,30 @@ namespace ProjectM.Client
|
||||
_depthPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
// DR-046: base class-select + prep-loadout panels (Staging) + the room-exit portal prompt (RoomExplore).
|
||||
VisualElement _classPanel, _prepPanel, _prepRowsHost;
|
||||
Label _classTitle, _prepTitle, _portalPrompt;
|
||||
Button _classWarBtn, _classRangerBtn;
|
||||
bool _classPanelBuilt, _prepPanelBuilt, _portalBuilt;
|
||||
|
||||
// B6 run-failed read (client-local edge state; see the tracker near the top of OnUpdate).
|
||||
byte _prevRunLifecycle;
|
||||
int _chargeAtLaunch;
|
||||
bool _wentInRun;
|
||||
float _runFailedUntil;
|
||||
int _classShownFor, _prepShownFor;
|
||||
|
||||
void UpdateClassPanel(bool show, byte classId)
|
||||
{
|
||||
if (!show) { if (_classPanel != null) _classPanel.style.display = DisplayStyle.None; _classShownFor = 0; return; }
|
||||
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
||||
if (!_classPanelBuilt) { BuildClassPanel(root); _classPanelBuilt = true; }
|
||||
int sig = classId + 1;
|
||||
if (_classShownFor != sig)
|
||||
{
|
||||
bool ranger = classId == ClassTraits.RangerClass;
|
||||
_classWarBtn.text = ranger ? "WARRIOR" : "WARRIOR ✓";
|
||||
_classRangerBtn.text = ranger ? "RANGER ✓" : "RANGER";
|
||||
_classWarBtn.SetEnabled(ranger);
|
||||
_classRangerBtn.SetEnabled(!ranger);
|
||||
_classShownFor = sig;
|
||||
}
|
||||
_classPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void BuildClassPanel(VisualElement root)
|
||||
{
|
||||
_classPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_classPanel.style.position = Position.Absolute;
|
||||
_classPanel.style.left = 12; _classPanel.style.top = Length.Percent(22);
|
||||
_classPanel.style.display = DisplayStyle.None;
|
||||
var box = new VisualElement();
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
|
||||
MenuUi.Round(box, 10);
|
||||
box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10;
|
||||
_classTitle = new Label("CLASS");
|
||||
_classTitle.style.color = MenuUi.Accent; _classTitle.style.fontSize = 14;
|
||||
_classTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _classTitle.style.marginBottom = 8;
|
||||
box.Add(_classTitle);
|
||||
_classWarBtn = MenuUi.Button("WARRIOR", () => ClassSelectSendSystem.RequestClass(ClassTraits.WarriorClass));
|
||||
_classWarBtn.style.marginBottom = 4; box.Add(_classWarBtn);
|
||||
_classRangerBtn = MenuUi.Button("RANGER", () => ClassSelectSendSystem.RequestClass(ClassTraits.RangerClass));
|
||||
box.Add(_classRangerBtn);
|
||||
_classPanel.Add(box); root.Add(_classPanel);
|
||||
}
|
||||
|
||||
void UpdatePrepPanel(bool show, int ore, int bio, int aether)
|
||||
{
|
||||
if (!show) { if (_prepPanel != null) _prepPanel.style.display = DisplayStyle.None; _prepShownFor = 0; return; }
|
||||
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
||||
if (!_prepPanelBuilt) { BuildPrepPanel(root); _prepPanelBuilt = true; }
|
||||
uint boughtMask = 0;
|
||||
foreach (var mods in SystemAPI.Query<DynamicBuffer<StatModifier>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||
{
|
||||
for (int m = 0; m < mods.Length; m++)
|
||||
{
|
||||
uint sid = mods[m].SourceId;
|
||||
if (sid >= Tuning.PrepSourceIdBase && sid < Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan)
|
||||
boughtMask |= (uint)(1 << (int)(sid - Tuning.PrepSourceIdBase));
|
||||
}
|
||||
break;
|
||||
}
|
||||
int sig = ore * 7 ^ bio * 13 ^ aether * 31 ^ (int)boughtMask * 101;
|
||||
if (sig == 0) sig = 1;
|
||||
if (_prepShownFor != sig)
|
||||
{
|
||||
_prepRowsHost.Clear();
|
||||
for (int i = 0; i < PrepCatalog.Count; i++)
|
||||
{
|
||||
var r = PrepCatalog.Rows[i];
|
||||
int have = r.CostResId == ResourceId.Aether ? aether : r.CostResId == ResourceId.Biomass ? bio : ore;
|
||||
bool bought = (boughtMask & (uint)(1 << r.Id)) != 0;
|
||||
string resName = r.CostResId == ResourceId.Aether ? "Aether" : r.CostResId == ResourceId.Biomass ? "Biomass" : "Ore";
|
||||
string label = PrepLabel(r.Id) + (bought ? " BOUGHT" : " - " + r.Cost + " " + resName);
|
||||
byte buyId = r.Id;
|
||||
var row = MenuUi.Button(label, () => PrepPurchaseSendSystem.RequestPrep(buyId));
|
||||
row.style.width = 240; row.style.marginBottom = 4;
|
||||
row.style.whiteSpace = WhiteSpace.Normal; row.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||
row.SetEnabled(!bought && have >= r.Cost);
|
||||
_prepRowsHost.Add(row);
|
||||
}
|
||||
_prepShownFor = sig;
|
||||
}
|
||||
_prepPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
static string PrepLabel(byte id) => id == 0 ? "+30 Max HP" : id == 1 ? "+12% Move Speed"
|
||||
: id == 2 ? "+20% Melee Damage" : "+20% Ranged Damage";
|
||||
|
||||
void BuildPrepPanel(VisualElement root)
|
||||
{
|
||||
_prepPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_prepPanel.style.position = Position.Absolute;
|
||||
_prepPanel.style.left = 12; _prepPanel.style.top = Length.Percent(45);
|
||||
_prepPanel.style.display = DisplayStyle.None;
|
||||
var box = new VisualElement();
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
|
||||
MenuUi.Round(box, 10);
|
||||
box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10;
|
||||
_prepTitle = new Label("PREP LOADOUT (lasts the run)");
|
||||
_prepTitle.style.color = MenuUi.Accent; _prepTitle.style.fontSize = 14;
|
||||
_prepTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _prepTitle.style.marginBottom = 8;
|
||||
box.Add(_prepTitle);
|
||||
_prepRowsHost = new VisualElement(); box.Add(_prepRowsHost);
|
||||
_prepPanel.Add(box); root.Add(_prepPanel);
|
||||
}
|
||||
|
||||
void UpdatePortalPrompt(RunInfo runInfo, bool haveRun)
|
||||
{
|
||||
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
||||
if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; }
|
||||
bool show = false, inRange = false;
|
||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.RoomExplore
|
||||
&& SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
{
|
||||
show = true; // room cleared -> ALWAYS steer the player to the (now visible) portal, not only when in range
|
||||
float3 portalPos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1));
|
||||
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||
{
|
||||
inRange = math.distance(lt.ValueRO.Position.xz, portalPos.xz) <= Tuning.PortalInteractRange;
|
||||
if (inRange)
|
||||
{
|
||||
var kb = UnityEngine.InputSystem.Keyboard.current;
|
||||
if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact();
|
||||
}
|
||||
break;
|
||||
}
|
||||
_portalPrompt.text = inRange
|
||||
? "PRESS E TO LEAVE — the haul comes home"
|
||||
: "ROOM CLEAR — reach the glowing portal to move on";
|
||||
}
|
||||
_portalPrompt.style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
}
|
||||
|
||||
void BuildPortalPrompt(VisualElement root)
|
||||
{
|
||||
_portalPrompt = HudUi.Display("PRESS E TO LEAVE — the haul comes home", 20, new Color(0.55f, 0.95f, 1f), TextAnchor.MiddleCenter);
|
||||
_portalPrompt.style.position = Position.Absolute;
|
||||
_portalPrompt.style.left = 0; _portalPrompt.style.right = 0; _portalPrompt.style.bottom = 240;
|
||||
_portalPrompt.pickingMode = PickingMode.Ignore;
|
||||
_portalPrompt.style.display = DisplayStyle.None;
|
||||
root.Add(_portalPrompt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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 ? AetherCyan : 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 = AetherCyan;
|
||||
_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