using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; using Unity.Transforms; // A6: boss-bar query reads LocalTransform (source-gen needs the using in this file) using Unity.Mathematics; // DR-046: portal proximity math (float3/.xz/math.distance) using UnityEngine; using UnityEngine.UIElements; namespace ProjectM.Client { /// /// Client-only screen HUD on UI Toolkit, skinned with the curated Synty sci-fi-soldier kit /// () over 's Aether palette so it reads in one visual language with /// the menu / pause / settings. A managed presentation () /// that OBSERVES the local player ghost + the global cycle / ledger / goal each frame and pushes values into a /// runtime UIDocument (shared PanelSettings, sortingOrder 50 so it sits behind the pause overlay's 100). The /// root is pickingMode = Ignore so the HUD never eats world clicks — only the build-palette slots pick. /// Layout (good-HUD spatial convention): persistent self-state hugs the corners (health bottom-left, resources /// top-left, threat top-right, build deck bottom-center); transient mission state (phase / countdown / wave / /// goal) lives center-top; a low-health vignette + hurt-flash + a scheme-aware build-mode control-hint bar give /// just-in-time feedback. EVERY skinned element is null-safe: with no it falls back to /// the flat-colour HUD. Presentation only (client world, no simulation, no rollback double-fire). /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] [UpdateAfter(typeof(OnboardingSystem))] // read OnboardingState.Active same-frame (single prompt voice) public partial class HudSystem : SystemBase { // ---- palette (Aether language; Synty white skins are tinted into these) ---- static readonly Color AetherCyan = new(0.30f, 0.85f, 1f); static readonly Color OreAmber = new(1f, 0.72f, 0.35f); static readonly Color BioGreen = new(0.55f, 0.85f, 0.45f); static readonly Color ChargeViolet = new(0.80f, 0.45f, 1f); static readonly Color CoreRed = new(1f, 0.40f, 0.32f); // END-1 Engine Core integrity bar static readonly Color PanelDark = new(0.08f, 0.11f, 0.15f, 0.90f); static readonly Color PanelWarm = new(0.16f, 0.09f, 0.09f, 0.88f); static readonly Color PipDim = new(0.25f, 0.30f, 0.36f, 0.9f); static readonly Color BlightRed = new(0.85f, 0.10f, 0.08f); static readonly Color ThreatWarm = new(1f, 0.62f, 0.4f); static readonly Color SlotIdleBg = new(0.09f, 0.11f, 0.15f, 0.92f); static readonly Color SlotSelBg = new(0.16f, 0.26f, 0.32f, 0.95f); static readonly Color SlotIdleBorder = new(1f, 1f, 1f, 0.08f); const int MaxPips = 12; const float ExpeditionRegionXMin = 500f; // camera x past this = the +1000 expedition region (DR-013) GameObject _hudGo; UIDocument _doc; bool _built; bool _themed; // HudTheme + PanelBox present (drives sprite-tint vs flat-colour retint) // vitals VisualElement _healthFill, _cooldownFill, _shieldRow, _cdRow; Label _healthText; // threat VisualElement _threatPanel, _threatIcon; Label _threatNum; // macro: banner + location + goal VisualElement _banner, _goalContainer, _goalPipsRow, _goalBar, _goalFill; Label _phaseText, _cycleText, _locationText, _goalText; // END-1: Engine Core integrity (losable base-heart) + overrun flash edge-detector VisualElement _coreContainer, _coreBar, _coreFill; Label _coreText; uint _lastOverrunTick; float _overrunFlashLeft; // 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). VisualElement _readyPanel, _readyPipRow; Button _readyBtn; 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 _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; bool _bossBarBuilt; VisualElement _depthPanel; int _depthShownFor; bool _depthBuilt; VisualElement _outcomeFlash; // one-shot gold/red full-screen flash when the outcome banner first lands float _outcomeFlashLeft; byte _outcomeFlashedFor; readonly List _pips = new(); // resources Label _aetherNum, _oreNum, _bioNum, _chargeNum; // build palette + hints VisualElement _paletteRow, _hintBar, _facingArrow, _buildDiscoveryChip; bool _paletteBuilt, _hintBuilt, _hintConveyor; byte _hintScheme = 255; readonly Dictionary _palette = new(); // overlays VisualElement _vignette, _downed; Label _downedText; // "RESPAWNING IN N" countdown (client-local: baked DelayTicks + death-edge latch) float _downedSince = -1f; float _prevHp, _flash; bool _haveHp; // personal inventory panel (read-only; toggled with I) VisualElement _invPanel, _invList, _equipList; bool _invOpen; EntityQuery _huskQuery; struct PaletteItem { public VisualElement Root; public Label Cost; public int CostAmount; public byte CostRes; public VisualElement Glow; public VisualElement Icon; } protected override void OnCreate() { _huskQuery = GetEntityQuery(ComponentType.ReadOnly()); } protected override void OnStartRunning() { if (_hudGo != null) return; MenuUi.EnsureEventSystem(); _hudGo = new GameObject("~HUD"); _doc = _hudGo.AddComponent(); _doc.panelSettings = MenuUi.LoadPanelSettings(); _doc.sortingOrder = 50; // behind the pause overlay (100) } protected override void OnDestroy() { if (_hudGo != null) Object.Destroy(_hudGo); } protected override void OnUpdate() { if (_doc == null) return; if (!_built) { var r = _doc.rootVisualElement; if (r == null) return; // panel not initialised yet (next frame) BuildTree(r); _built = true; } // Job-safety insurance (matches the sibling presentation systems + CLAUDE.md): finish any jobs writing // the components we read on the main thread before reading them. No job writes these today, but this // stays correct the day a Health/stats writer is parallelised. EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system bool haveTick = SystemAPI.TryGetSingleton(out var nt); int huskCount = _huskQuery.CalculateEntityCount(); // ---- Macro: phase + cycle + countdown (center-top banner) ---- bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); // hoisted: the phase banner is lifecycle-aware (Phase 0 fix — it read "AT BASE" inside expedition rooms) // B6: run-failed read (review-confirmed design: NEVER key on Returning — it is a 1-tick transient and // the Charge bank lands a tick after it; detect the (in-run)->Staging edge with a launch-cached Charge. // Lifecycle + Charge ride the SAME director ghost snapshot, so at the Staging edge the bank has arrived). if (haveRun) { bool haveGoalNow = SystemAPI.TryGetSingleton(out var goalSnap); byte lcNow = runInfo.Lifecycle; if (lcNow == RunLifecycle.Launching && _prevRunLifecycle == RunLifecycle.Staging) { _chargeAtLaunch = haveGoalNow ? goalSnap.Charge : 0; _wentInRun = false; } if (lcNow == RunLifecycle.InRoom) _wentInRun = true; if (lcNow == RunLifecycle.Staging && _prevRunLifecycle != RunLifecycle.Staging && _wentInRun) { if (haveGoalNow && goalSnap.Charge <= _chargeAtLaunch) _runFailedUntil = (float)SystemAPI.Time.ElapsedTime + 6f; // wipe/abort: nothing banked _wentInRun = false; } _prevRunLifecycle = lcNow; } bool haveCycle = SystemAPI.TryGetSingleton(out var cyc); bool siege = haveCycle && cyc.Phase == CyclePhase.Siege; bool goalFull = SystemAPI.TryGetSingleton(out var goalNow) && goalNow.Target > 0 && goalNow.Charge >= goalNow.Target; bool finalSiege = siege && goalFull; // END-2: the climactic final siege (goal cap reached) bool onRun = haveRun && !siege && runInfo.Lifecycle != RunLifecycle.Staging; // mid-run: the base's Calm label is wrong if (haveCycle) { var endTick = new NetworkTick(cyc.PhaseEndTick); bool arming = haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick); bool finalArming = !siege && goalFull && arming; // the cap-reached arming window before the final wave int secs = arming ? (endTick.TicksSince(nt.ServerTick) / 60 + 1) : 0; string detail; if (siege) detail = (finalSiege ? "FINAL SIEGE" : "WAVE " + cyc.WaveNumber) + " - " + huskCount + " HUSKS"; else if (arming) detail = (finalArming ? "FINAL SIEGE INCOMING" : "INCURSION") + " - " + secs + "s"; else detail = ""; // END-2: the climax reads distinct (intense red), not a normal incursion/wave. var col = finalSiege || finalArming ? new Color(1f, 0.28f, 0.22f) : PhaseColor(cyc.Phase); _phaseText.text = (finalSiege ? "HOLD THE ENGINE" : onRun ? "ON EXPEDITION" : PhaseLabel(cyc.Phase)) + (detail.Length > 0 ? " - " + detail : ""); _phaseText.style.color = col; _cycleText.text = "CYCLE " + cyc.CycleNumber; _banner.style.borderBottomColor = col; RetintPanel(_banner, siege ? PanelWarm : PanelDark); } else { _phaseText.text = ""; _cycleText.text = ""; } // ---- Location line (banner sub-line) — Step 14: driven by the replicated RunInfo lifecycle FSM ---- // (the old camera-X + walk-in-gate copy died with the gate; siege/final overrides below still win). var cam = Camera.main; // camera-X region signal still feeds downstream panels (atmosphere/threat) bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin; SystemAPI.TryGetSingleton(out var obj); if (haveRun && !siege && !finalSiege) { switch (runInfo.Lifecycle) { case RunLifecycle.Staging: // The READY panel (bottom-center) owns the action + N/M count; the top line frames intent. if ((float)SystemAPI.Time.ElapsedTime < _runFailedUntil) { // B6: a silent wipe used to land players home with ZERO explanation. _locationText.text = "EXPEDITION FAILED - the party fell; nothing was banked"; _locationText.style.color = new Color(1f, 0.35f, 0.3f); } else { _locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch"; _locationText.style.color = new Color(0.55f, 0.85f, 1f); } break; case RunLifecycle.Launching: { // Wrap-safe countdown (post-impl review): the client's PREDICTED tick passes LaunchTick // near zero while Lifecycle is still Launching — signed TicksSince, never raw uint math. int secs = 0; if (runInfo.LaunchTick != 0 && SystemAPI.TryGetSingleton(out var ntime) && ntime.ServerTick.IsValid) { int ticksLeft = new NetworkTick(runInfo.LaunchTick).TicksSince(ntime.ServerTick); if (ticksLeft > 0) secs = ticksLeft / 60 + 1; } _locationText.text = "LAUNCHING IN " + secs + " - un-ready [T] to abort"; _locationText.style.color = new Color(1f, 0.9f, 0.4f); break; } case RunLifecycle.InRoom: { string room = "ROOM " + (runInfo.CurrentRoom + 1) + "/" + runInfo.RoomCount + " " + RoomTypeLabel(runInfo.CurrentRoomType); _locationText.text = obj.State == ExpeditionObjectiveState.Active ? room + " - " + obj.Remaining + " enemies remaining" : room + " - clear it to advance"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); break; } case RunLifecycle.RoomReward: _locationText.text = "ROOM CLEARED - choose your boon"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); break; case RunLifecycle.RoomExplore: // Phase 0: without this case the RoomReward text stuck through the loot window _locationText.text = "ROOM CLEAR - grab the loot, take the portal to move on"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); break; case RunLifecycle.RouteSelect: _locationText.text = "CHOOSE YOUR PATH"; _locationText.style.color = new Color(0.55f, 0.85f, 1f); break; case RunLifecycle.Returning: _locationText.text = "RETURNING HOME..."; _locationText.style.color = new Color(0.7f, 0.9f, 1f); break; } } else if (!haveRun) { _locationText.text = finalSiege ? "FINAL SIEGE - hold the Engine, this is the last stand" : siege ? "DEFEND THE BASE - hold the line" : "MINE THE CRYSTALS - any attack harvests Ore, then BUILD"; _locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f) : siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f); } else { _locationText.text = finalSiege ? "FINAL SIEGE - hold the Engine, this is the last stand" : "DEFEND THE BASE - hold the line"; _locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f) : new Color(1f, 0.55f, 0.4f); } // ---- 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>().WithAll()) { localOffer = off.ValueRO; hasOffer = true; break; } BlobAssetReference boonPool = default; if (SystemAPI.TryGetSingleton(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. int rTotal = 0, rReady = 0; bool localReady = false; int launchSecs = 0; bool terminal = SystemAPI.TryGetSingleton(out var readyOc) && readyOc.Value != RunOutcomeId.InProgress; bool readyShow = haveRun && !terminal && !goalFull /* D6: goal full -> final defense armed, launching is refused server-side */ && (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching); if (readyShow) { foreach (var pr in SystemAPI.Query>().WithAll()) { rTotal++; if (pr.ValueRO.Value != 0) rReady++; } foreach (var pr in SystemAPI.Query>().WithAll()) localReady = pr.ValueRO.Value != 0; if (runInfo.Lifecycle == RunLifecycle.Launching && runInfo.LaunchTick != 0 && SystemAPI.TryGetSingleton(out var lnt) && lnt.ServerTick.IsValid) { int tl = new NetworkTick(runInfo.LaunchTick).TicksSince(lnt.ServerTick); if (tl > 0) launchSecs = tl / 60 + 1; } } UpdateReadyPanel(readyShow, runInfo, rTotal, rReady, localReady, launchSecs); // Boss presence bar. The boss is a scaled Charger (EnemyTelegraph.Kind==KindCharger, baked/client-safe) // in the EXPEDITION region — filtering on both excludes phase-two summoned swarmers AND a base-region // siege enemy a dead teammate can see. Health.Max is now a [GhostField] (replicated x8 for the boss), so // the fraction reads true directly. bool bossAlive = false; float bossHp = 0f, bossMax = 0f; if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss) { foreach (var (bhq, tele, blt) in SystemAPI.Query, RefRO, RefRO>().WithAll()) { if (tele.ValueRO.Kind != ZoneEnemyMath.KindCharger) continue; // the boss is a Charger; skip summoned swarmers if (blt.ValueRO.Position.x <= ExpeditionRegionXMin) continue; // expedition only (not a base siege enemy) if (bhq.ValueRO.Max > bossMax) { bossMax = bhq.ValueRO.Max; bossHp = bhq.ValueRO.Current; bossAlive = bhq.ValueRO.Current > 0f; } } } UpdateBossBar(bossAlive, bossHp, bossMax); // Run-depth dots — keeps the roguelite spine visible while fighting (the map only shows at gates). UpdateRunDepth(haveRun ? runInfo : default, haveRun); // ---- Goal (hex-pip meter, or a continuous bar for large targets) ---- if (SystemAPI.TryGetSingleton(out var goal)) { _goalContainer.style.display = DisplayStyle.Flex; float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f; _goalText.text = "GOAL " + goal.Charge + " / " + goal.Target; if (goal.Target >= 1 && goal.Target <= MaxPips) { _goalPipsRow.style.display = DisplayStyle.Flex; _goalBar.style.display = DisplayStyle.None; int active = Mathf.Min(goal.Charge, goal.Target); // Charge is the integer pip count; never over-fill for (int i = 0; i < _pips.Count; i++) { bool show = i < goal.Target; _pips[i].style.display = show ? DisplayStyle.Flex : DisplayStyle.None; if (show) SetPip(_pips[i], i < active); } } else { _goalPipsRow.style.display = DisplayStyle.None; _goalBar.style.display = DisplayStyle.Flex; HudUi.SetFill(_goalFill, gfrac); } } else { _goalContainer.style.display = DisplayStyle.None; } // ---- Resources (feed palette affordability) ---- int aether = 0, ore = 0, bio = 0, charge = 0; if (SystemAPI.TryGetSingletonEntity(out var ledgerE)) { var buf = SystemAPI.GetBuffer(ledgerE); for (int i = 0; i < buf.Length; i++) { var en = buf[i]; if (en.ItemId == ResourceId.Aether) aether = en.Count; else if (en.ItemId == ResourceId.Ore) ore = en.Count; else if (en.ItemId == ResourceId.Biomass) bio = en.Count; else if (en.ItemId == ResourceId.Charge) charge = en.Count; } } _aetherNum.text = aether.ToString(); _oreNum.text = ore.ToString(); _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>().WithAll()) { localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id); haveLocalPlayer = true; break; } bool metaShow = false; BlobAssetReference metaPool = default; DynamicBuffer metaRecord = default; if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege && SystemAPI.TryGetSingleton(out var metaCat) && metaCat.Value.IsCreated && SystemAPI.TryGetSingletonBuffer(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.) // EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one // broken turret): a dry base during a siege tells the player to build a Fabricator. if (siege && charge == 0 && !onExpedition) { _locationText.text = "TURRETS OUT OF AMMO - build a Fabricator (Ore -> ammo)"; _locationText.style.color = new Color(1f, 0.4f, 0.9f); } // ---- Engine Core integrity (END-1): a red base-heart bar; an overrun stamps a transient pulse we flash ---- if (SystemAPI.TryGetSingleton(out var core) && core.Max > 0) { _coreContainer.style.display = DisplayStyle.Flex; float cfrac = Mathf.Clamp01(core.Current / (float)core.Max); HudUi.SetFill(_coreFill, cfrac); _coreText.text = "CORE " + core.Current + " / " + core.Max; _coreText.style.color = Color.Lerp(BlightRed, CoreRed, cfrac); // shifts to danger as it drops if (core.OverrunTick != 0 && core.OverrunTick != _lastOverrunTick) { _lastOverrunTick = core.OverrunTick; // edge-detect the replicated breach pulse _overrunFlashLeft = 3.5f; } } else { _coreContainer.style.display = DisplayStyle.None; } // Overrun flash overrides the location line (runs AFTER the EB-2 cue so it wins; at a breach Phase is Calm). if (_overrunFlashLeft > 0f) { _overrunFlashLeft -= dt; _locationText.text = "BASE OVERRUN - resources lost; the Core will recover"; _locationText.style.color = new Color(1f, 0.3f, 0.25f); } // First-run onboarding owns the prompt voice: while a coach-mark step is showing, blank the HUD's own // location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay). if (OnboardingState.SuppressLocationLine) _locationText.text = ""; // D4: blank only for the early base-framing steps; room/siege/charge cues survive // D6: goal full but the final siege hasn't spawned yet (the arming gap) -> the READY panel is hidden; tell the // player what's coming instead of a stale base line (goalFull is replicated; RunPhase is server-only). if (haveRun && goalFull && !terminal && !siege && !finalSiege) { _locationText.text = "GOAL REACHED - FINAL DEFENSE INCOMING: hold the Engine!"; _locationText.style.color = new Color(1f, 0.35f, 0.28f); } // ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ---- if (SystemAPI.TryGetSingleton(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress) { bool win = runOutcome.Value == RunOutcomeId.Victory; if (_outcomeFlashedFor != runOutcome.Value) { // One-shot landing flourish: full-screen color flash + camera kick — the beat gets a payoff. _outcomeFlashedFor = runOutcome.Value; _outcomeFlashLeft = win ? 0.9f : 0.7f; PrototypeCameraRig.PunchFov(win ? 5f : 3f, win ? 420f : 260f); PrototypeCameraRig.AddShake(win ? 0.25f : 0.5f); } _runBanner.style.display = DisplayStyle.Flex; _runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN"; _runBannerText.style.color = win ? new Color(0.45f, 0.95f, 1f) : new Color(1f, 0.35f, 0.3f); _runBannerSub.text = win ? "VICTORY - the final siege is broken" : "THE FINAL STAND FELL"; _runBannerSub.style.color = win ? new Color(0.7f, 0.95f, 1f) : new Color(1f, 0.6f, 0.5f); } else { _runBanner.style.display = DisplayStyle.None; _outcomeFlashedFor = 0; } // Outcome flash decay (lazy element; the banner dim sits above it, the world below). if (_outcomeFlashLeft > 0f) { if (_outcomeFlash == null && _doc != null && _doc.rootVisualElement != null) { _outcomeFlash = new VisualElement { pickingMode = PickingMode.Ignore }; _outcomeFlash.style.position = Position.Absolute; _outcomeFlash.style.left = 0; _outcomeFlash.style.right = 0; _outcomeFlash.style.top = 0; _outcomeFlash.style.bottom = 0; _doc.rootVisualElement.Add(_outcomeFlash); } _outcomeFlashLeft -= dt; if (_outcomeFlash != null) { bool winFlash = _outcomeFlashedFor == RunOutcomeId.Victory; var fc = winFlash ? new Color(1f, 0.85f, 0.35f) : new Color(1f, 0.20f, 0.15f); _outcomeFlash.style.backgroundColor = new Color(fc.r, fc.g, fc.b, Mathf.Clamp01(_outcomeFlashLeft) * 0.35f); _outcomeFlash.style.display = DisplayStyle.Flex; } } else if (_outcomeFlash != null) { _outcomeFlash.style.display = DisplayStyle.None; } // ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ---- bool showThreat = siege || huskCount > 0; _threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None; if (showThreat) { float intensity = Mathf.Clamp01(huskCount / 30f); Color tc = siege ? Color.Lerp(ThreatWarm, BlightRed, intensity) : ThreatWarm; _threatNum.text = huskCount.ToString(); _threatNum.style.color = tc; _threatIcon.style.unityBackgroundImageTintColor = tc; RetintPanel(_threatPanel, siege ? PanelWarm : PanelDark); } // ---- Build palette + control hints (bottom-center) ---- UpdatePalette(aether, ore, bio, onExpedition); bool paletteOpen = BuildPaletteState.PaletteOpen && !onExpedition && _paletteBuilt; bool buildActive = paletteOpen && BuildPaletteState.Active; if (buildActive) { byte scheme = AimPresentation.Scheme; bool conv = BuildPaletteState.Selected == StructureType.Conveyor; if (!_hintBuilt || _hintScheme != scheme || _hintConveyor != conv) RebuildHints(scheme, conv); if (conv && _facingArrow != null) _facingArrow.style.rotate = new StyleRotate(new Rotate(new Angle(FacingDegrees(BuildPaletteState.Direction)))); _hintBar.style.display = DisplayStyle.Flex; } else { _hintBar.style.display = DisplayStyle.None; } // Build-mode discovery chip: a subtle "Tab/Y — BUILD" hint when the palette is hidden at base (Slice 1). _buildDiscoveryChip.style.display = (!onExpedition && !BuildPaletteState.PaletteOpen) ? DisplayStyle.Flex : DisplayStyle.None; // ---- Per-player vitals ---- bool found = false; float hp = 0f, maxHp = 1f, cdFrac = 1f; bool dead = false, shielded = false; foreach (var (health, effChar, effAbility, cd, invuln, entity) in SystemAPI.Query, RefRO, RefRO, RefRO, RefRO>() .WithAll().WithEntityAccess()) { found = true; hp = health.ValueRO.Current; maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max; dead = SystemAPI.IsComponentEnabled(entity); uint nextFire = cd.ValueRO.NextFireTick; int cdTicks = effAbility.ValueRO.CooldownTicks; var nextTick = new NetworkTick(nextFire); cdFrac = (haveTick && nextFire != 0 && cdTicks > 0 && nextTick.IsValid && nextTick.IsNewerThan(nt.ServerTick)) ? Mathf.Clamp01(1f - nextTick.TicksSince(nt.ServerTick) / (float)cdTicks) : 1f; uint invulnUntil = invuln.ValueRO.UntilTick; var invulnTick = new NetworkTick(invulnUntil); shielded = haveTick && invulnUntil != 0 && invulnTick.IsValid && invulnTick.IsNewerThan(nt.ServerTick); break; } _doc.rootVisualElement.style.display = (found || haveCycle) ? DisplayStyle.Flex : DisplayStyle.None; // ---- Low-health vignette + hurt flash (full-screen) ---- _flash = HudVisualMath.DecayFlash(_flash, dt); if (found) { float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f; if (_haveHp && hp < _prevHp - 1f) _flash = HudVisualMath.HurtFlashKick; _prevHp = hp; _haveHp = true; float vigOp = dead ? 0f : HudVisualMath.CombinedOpacity(frac, _flash); _vignette.style.opacity = vigOp; _vignette.style.display = vigOp > 0.001f ? DisplayStyle.Flex : DisplayStyle.None; HudUi.SetFill(_healthFill, frac); _healthFill.style.backgroundColor = shielded ? new Color(0.45f, 0.85f, 1f) : Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac); _healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp); _shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None; HudUi.SetFill(_cooldownFill, cdFrac); // A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads. if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f; if (dead) { // Client-local countdown: latch the death edge; the baked (non-replicated) DelayTicks is the // honest duration — RespawnTick itself is server-only. if (_downedSince < 0f) _downedSince = (float)SystemAPI.Time.ElapsedTime; int delayTicks = 180; foreach (var rs in SystemAPI.Query>().WithAll()) { delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; } float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince); _downedText.text = left > 0.05f ? "RESPAWNING IN " + Mathf.CeilToInt(left) : "RESPAWNING..."; } else _downedSince = -1f; _downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None; } else { _haveHp = false; _flash = 0f; _vignette.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(); EntityManager.CompleteDependencyBeforeRO(); bool haveItemDb = SystemAPI.TryGetSingleton(out var itemDb); _invPanel.style.display = DisplayStyle.Flex; _invList.Clear(); int shown = 0; foreach (var bag in SystemAPI.Query>() .WithAll()) { for (int i = 0; i < bag.Length; i++) { var slot = bag[i]; if (slot.ItemId == 0 || slot.Count <= 0) continue; AddInvRow(slot.ItemId, ItemName(haveItemDb, itemDb, slot.ItemId), ItemTint(slot.ItemId), slot.Count, IsEquippable(haveItemDb, itemDb, slot.ItemId)); shown++; } break; } if (shown == 0) _invList.Add(HudUi.Text("(empty)", 13, MenuUi.SubCol, TextAnchor.MiddleLeft)); _equipList.Clear(); foreach (var slots in SystemAPI.Query>() .WithAll()) { for (byte s = 0; s < EquipSlotId.Count && s < slots.Length; s++) { ushort id = slots[s].ItemId; string label = SlotName(s) + ": " + (id == 0 ? "-" : ItemName(haveItemDb, itemDb, id)); AddEquipRow(s, label, id != 0); } break; } } else { _invPanel.style.display = DisplayStyle.None; } } // ---- per-frame helpers ---- void RetintPanel(VisualElement p, Color c) { if (_themed) p.style.unityBackgroundImageTintColor = c; else p.style.backgroundColor = c; } void SetPip(VisualElement pip, bool active) { var theme = HudTheme.Get(); var spr = active ? theme?.PipActive : theme?.PipInactive; if (spr != null) { pip.style.backgroundImage = new StyleBackground(Background.FromSprite(spr)); pip.style.unityBackgroundImageTintColor = active ? AetherCyan : PipDim; pip.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Contain)); } else { pip.style.backgroundColor = active ? AetherCyan : PipDim; MenuUi.Round(pip, 3); } } // DR-042 C6d: Harvester/Conveyor/Pylon are dead (unwired automation) -> hidden from the build palette // (catalog + prefabs stay baked, code-intact per DR-020). Only Turret/Wall/Fabricator are buildable in the UI. static bool IsPaletteType(byte type) => type != StructureType.Pylon && type != StructureType.Harvester && type != StructureType.Conveyor; void UpdatePalette(int aether, int ore, int bio, bool onExpedition) { if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity(out var catE)) { var cat = SystemAPI.GetBuffer(catE); for (int i = 0; i < cat.Length; i++) if (IsPaletteType(cat[i].Type)) AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId); _paletteBuilt = true; } if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; } bool showPalette = !onExpedition && BuildPaletteState.PaletteOpen; _paletteRow.style.display = showPalette ? DisplayStyle.Flex : DisplayStyle.None; foreach (var kv in _palette) { var item = kv.Value; int have = item.CostRes == ResourceId.Aether ? aether : item.CostRes == ResourceId.Biomass ? bio : ore; bool affordable = have >= item.CostAmount; bool selected = BuildPaletteState.Selected == kv.Key; item.Root.style.opacity = affordable ? 1f : 0.5f; item.Cost.style.color = affordable ? new Color(0.7f, 0.95f, 0.8f) : new Color(1f, 0.5f, 0.4f); if (item.Icon != null) item.Icon.style.unityBackgroundImageTintColor = affordable ? AetherCyan : new Color(0.5f, 0.55f, 0.6f); MenuUi.Border(item.Root, selected ? MenuUi.Accent : SlotIdleBorder, selected ? 2 : 1); item.Root.style.backgroundColor = selected ? SlotSelBg : SlotIdleBg; if (item.Glow != null) item.Glow.style.opacity = selected ? 0.6f : 0f; } } void AddPaletteItem(byte type, int cost, byte costRes) { if (type == 0 || _palette.ContainsKey(type)) return; var theme = HudTheme.Get(); var root = new VisualElement(); root.style.width = 86; root.style.marginLeft = 4; root.style.marginRight = 4; root.style.paddingTop = 8; root.style.paddingBottom = 6; root.style.alignItems = Align.Center; root.style.backgroundColor = SlotIdleBg; root.pickingMode = PickingMode.Position; MenuUi.Round(root, 6); MenuUi.Border(root, SlotIdleBorder, 1); // selection glow: a soft Synty glow filling the slot behind everything, opacity toggled on select. var glow = new VisualElement(); glow.style.position = Position.Absolute; glow.style.left = 3; glow.style.right = 3; glow.style.top = 4; glow.style.bottom = 4; glow.pickingMode = PickingMode.Ignore; glow.style.opacity = 0f; if (theme != null && theme.Glow != null) { glow.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Glow)); glow.style.unityBackgroundImageTintColor = AetherCyan; glow.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover)); } root.Add(glow); var iconEl = HudUi.Icon(theme != null ? theme.StructureIcon(type) : null, 44, AetherCyan); root.Add(iconEl); var nameLabel = HudUi.Text(StructureName(type), 12, MenuUi.TextCol, TextAnchor.MiddleCenter); nameLabel.style.marginTop = 2; root.Add(nameLabel); var costRow = new VisualElement(); costRow.style.flexDirection = FlexDirection.Row; costRow.style.alignItems = Align.Center; costRow.style.marginTop = 2; costRow.pickingMode = PickingMode.Ignore; var costIcon = HudUi.Icon(ResourceSprite(theme, costRes), 14, ResourceTint(costRes)); costIcon.style.marginRight = 3; costRow.Add(costIcon); var costLabel = HudUi.Display(cost.ToString(), 13, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter); costRow.Add(costLabel); root.Add(costRow); byte t = type; root.RegisterCallback(_ => BuildPaletteState.Select(BuildPaletteState.Selected == t ? (byte)0 : t)); _paletteRow.Add(root); _palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost, CostRes = costRes, Glow = glow, Icon = iconEl }; } void RebuildHints(byte scheme, bool conveyor) { _hintBar.Clear(); _facingArrow = null; var theme = HudTheme.Get(); bool pad = scheme == InputSchemeId.Gamepad; AddHint(pad ? theme?.PadPlace : theme?.KbmPlace, pad ? "A" : "LMB", "PLACE"); AddHint(pad ? theme?.PadCancel : theme?.KbmCancel, pad ? "B" : "RMB", "CANCEL"); if (conveyor) { // Rotate hint + a LIVE facing arrow (resolves the DR-021 conveyor-facing indicator). Only conveyors // rotate, so this chip is gated to them — the other buildables don't show a meaningless ROTATE. var chip = MakeChip(); chip.Add(HudUi.Glyph(pad ? theme?.PadRotate : null, pad ? "LB" : "R", 26)); var lbl = HudUi.Text("FACING", 12, MenuUi.SubCol, TextAnchor.MiddleLeft); lbl.style.marginLeft = 5; lbl.style.marginRight = 6; chip.Add(lbl); _facingArrow = HudUi.Icon(theme != null ? theme.ConveyorIcon : null, 24, AetherCyan); chip.Add(_facingArrow); _hintBar.Add(chip); } AddHint(pad ? theme?.PadExit : null, pad ? "MENU" : "ESC", "EXIT"); _hintScheme = scheme; _hintConveyor = conveyor; _hintBuilt = true; } VisualElement MakeChip() { var chip = new VisualElement(); chip.style.flexDirection = FlexDirection.Row; chip.style.alignItems = Align.Center; chip.style.marginLeft = 8; chip.style.marginRight = 8; chip.pickingMode = PickingMode.Ignore; return chip; } void AddHint(Sprite glyph, string fallback, string action) { var chip = MakeChip(); chip.Add(HudUi.Glyph(glyph, fallback, 26)); var lbl = HudUi.Text(action, 12, MenuUi.SubCol, TextAnchor.MiddleLeft); lbl.style.marginLeft = 5; chip.Add(lbl); _hintBar.Add(chip); } // ---- UITK construction ---- void BuildTree(VisualElement root) { var theme = HudTheme.Get(); _themed = theme != null && theme.PanelBox != null; 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 BuildVignette(root); BuildVitals(root); BuildThreat(root); BuildMacro(root); BuildResources(root); BuildPaletteRow(root); BuildHintBar(root); BuildDiscoveryChip(root); BuildDowned(root); BuildInventory(root); BuildRunBanner(root); } void BuildVignette(VisualElement root) { _vignette = new VisualElement(); _vignette.style.position = Position.Absolute; _vignette.style.left = 0; _vignette.style.right = 0; _vignette.style.top = 0; _vignette.style.bottom = 0; _vignette.pickingMode = PickingMode.Ignore; var theme = HudTheme.Get(); if (theme != null && theme.Vignette != null) { _vignette.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Vignette)); _vignette.style.unityBackgroundImageTintColor = BlightRed; _vignette.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover)); } else { _vignette.style.backgroundColor = new Color(BlightRed.r, BlightRed.g, BlightRed.b, 1f); } _vignette.style.display = DisplayStyle.None; root.Add(_vignette); } void BuildVitals(VisualElement root) { var panel = HudUi.Panel(PanelDark); panel.style.position = Position.Absolute; panel.style.left = 40; panel.style.bottom = 40; panel.style.paddingLeft = 14; panel.style.paddingRight = 14; panel.style.paddingTop = 12; panel.style.paddingBottom = 12; panel.style.alignItems = Align.FlexStart; var theme = HudTheme.Get(); // shield chip (shown only while the respawn shield is active) _shieldRow = new VisualElement(); _shieldRow.style.flexDirection = FlexDirection.Row; _shieldRow.style.alignItems = Align.Center; _shieldRow.style.marginBottom = 6; _shieldRow.pickingMode = PickingMode.Ignore; var shieldIcon = HudUi.Icon(theme != null ? theme.ShieldIcon : null, 22, AetherCyan); shieldIcon.style.marginRight = 6; _shieldRow.Add(shieldIcon); _shieldRow.Add(HudUi.Text("SHIELDED", 13, new Color(0.45f, 0.85f, 1f), TextAnchor.MiddleLeft)); _shieldRow.style.display = DisplayStyle.None; panel.Add(_shieldRow); // cooldown row: weapon icon + thin bar _cdRow = new VisualElement(); _cdRow.style.flexDirection = FlexDirection.Row; _cdRow.style.alignItems = Align.Center; _cdRow.style.marginBottom = 6; _cdRow.pickingMode = PickingMode.Ignore; var cdIcon = HudUi.Icon(theme != null ? theme.CooldownIcon : null, 22, AetherCyan); cdIcon.style.marginRight = 8; _cdRow.Add(cdIcon); var cdBar = HudUi.Bar(420, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill); _cdRow.Add(cdBar); panel.Add(_cdRow); // health row: health icon + big bar with numeric overlay var hpRow = new VisualElement(); hpRow.style.flexDirection = FlexDirection.Row; hpRow.style.alignItems = Align.Center; hpRow.pickingMode = PickingMode.Ignore; var hpIcon = HudUi.Icon(theme != null ? theme.HealthIcon : null, 34, new Color(0.95f, 0.4f, 0.4f)); hpIcon.style.marginRight = 8; hpRow.Add(hpIcon); var hpBar = HudUi.Bar(420, 40, new Color(0.25f, 0.9f, 0.5f), out _healthFill); _healthText = HudUi.Display("100 / 100", 24, Color.white, TextAnchor.MiddleCenter); _healthText.style.position = Position.Absolute; _healthText.style.left = 0; _healthText.style.right = 0; _healthText.style.top = 0; _healthText.style.bottom = 0; hpBar.Add(_healthText); hpRow.Add(hpBar); panel.Add(hpRow); root.Add(panel); } void BuildThreat(VisualElement root) { _threatPanel = HudUi.Panel(PanelDark); _threatPanel.style.position = Position.Absolute; _threatPanel.style.right = 40; _threatPanel.style.top = 28; _threatPanel.style.paddingLeft = 16; _threatPanel.style.paddingRight = 16; _threatPanel.style.paddingTop = 8; _threatPanel.style.paddingBottom = 8; _threatPanel.style.alignItems = Align.FlexEnd; var theme = HudTheme.Get(); var row = new VisualElement(); row.style.flexDirection = FlexDirection.Row; row.style.alignItems = Align.Center; row.pickingMode = PickingMode.Ignore; _threatIcon = HudUi.Icon(theme != null ? theme.ThreatIcon : null, 36, ThreatWarm); _threatIcon.style.marginRight = 8; row.Add(_threatIcon); _threatNum = HudUi.Display("0", 34, ThreatWarm, TextAnchor.MiddleRight); row.Add(_threatNum); _threatPanel.Add(row); var caption = HudUi.Text("HUSKS", 13, MenuUi.SubCol, TextAnchor.MiddleRight); caption.style.letterSpacing = 4; _threatPanel.Add(caption); _threatPanel.style.display = DisplayStyle.None; root.Add(_threatPanel); } void BuildMacro(VisualElement root) { var macro = HudUi.Group(Align.Center); macro.style.position = Position.Absolute; macro.style.top = 16; macro.style.left = 0; macro.style.right = 0; var theme = HudTheme.Get(); // banner: objective icon + phase line + cycle, phase-coloured underline _banner = HudUi.Panel(PanelDark); _banner.style.flexDirection = FlexDirection.Row; _banner.style.alignItems = Align.Center; _banner.style.paddingLeft = 22; _banner.style.paddingRight = 22; _banner.style.paddingTop = 8; _banner.style.paddingBottom = 8; _banner.style.borderBottomWidth = 2; _banner.style.borderBottomColor = AetherCyan; var bIcon = HudUi.Icon(theme != null ? theme.GoalIcon : null, 26, AetherCyan); bIcon.style.marginRight = 10; _banner.Add(bIcon); _phaseText = HudUi.Display("", 30, AetherCyan, TextAnchor.MiddleCenter); _banner.Add(_phaseText); _cycleText = HudUi.Text("", 14, MenuUi.SubCol, TextAnchor.MiddleCenter); _cycleText.style.marginLeft = 14; _banner.Add(_cycleText); macro.Add(_banner); _locationText = HudUi.Text("", 15, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter); _locationText.style.marginTop = 5; macro.Add(_locationText); // goal: hex-pip meter (or fallback bar) + numeral _goalContainer = HudUi.Group(Align.Center); _goalContainer.style.marginTop = 8; var goalLine = new VisualElement(); goalLine.style.flexDirection = FlexDirection.Row; goalLine.style.alignItems = Align.Center; goalLine.pickingMode = PickingMode.Ignore; _goalPipsRow = new VisualElement(); _goalPipsRow.style.flexDirection = FlexDirection.Row; _goalPipsRow.style.alignItems = Align.Center; _goalPipsRow.pickingMode = PickingMode.Ignore; for (int i = 0; i < MaxPips; i++) { var pip = new VisualElement(); pip.style.width = 22; pip.style.height = 22; pip.style.marginLeft = 2; pip.style.marginRight = 2; pip.style.flexShrink = 0; pip.pickingMode = PickingMode.Ignore; pip.style.display = DisplayStyle.None; _pips.Add(pip); _goalPipsRow.Add(pip); } goalLine.Add(_goalPipsRow); _goalText = HudUi.Display("GOAL 0 / 10", 16, AetherCyan, TextAnchor.MiddleCenter); _goalText.style.marginLeft = 10; goalLine.Add(_goalText); _goalContainer.Add(goalLine); // fallback continuous bar (large targets) _goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill); _goalBar.style.marginTop = 4; _goalBar.style.display = DisplayStyle.None; _goalContainer.Add(_goalBar); macro.Add(_goalContainer); // END-1: Engine Core integrity bar (red) — the losable base-heart meter. _coreContainer = HudUi.Group(Align.Center); _coreContainer.style.marginTop = 6; var coreLine = new VisualElement(); coreLine.style.flexDirection = FlexDirection.Row; coreLine.style.alignItems = Align.Center; coreLine.pickingMode = PickingMode.Ignore; _coreBar = HudUi.Bar(360, 14, CoreRed, out _coreFill); coreLine.Add(_coreBar); _coreText = HudUi.Text("CORE 100 / 100", 13, CoreRed, TextAnchor.MiddleLeft); _coreText.style.marginLeft = 10; coreLine.Add(_coreText); _coreContainer.Add(coreLine); _coreContainer.style.display = DisplayStyle.None; macro.Add(_coreContainer); root.Add(macro); } void BuildResources(VisualElement root) { var strip = HudUi.Panel(PanelDark); strip.style.position = Position.Absolute; strip.style.left = 40; strip.style.top = 28; strip.style.flexDirection = FlexDirection.Row; strip.style.alignItems = Align.Center; strip.style.paddingLeft = 14; strip.style.paddingRight = 14; strip.style.paddingTop = 8; strip.style.paddingBottom = 8; var theme = HudTheme.Get(); strip.Add(ResourceChip(theme != null ? theme.AetherIcon : null, AetherCyan, "0", out _aetherNum, 26, 20)); strip.Add(ResourceChip(theme != null ? theme.OreIcon : null, OreAmber, "0", out _oreNum, 30, 22)); strip.Add(ResourceChip(theme != null ? theme.BioIcon : null, BioGreen, "0", out _bioNum, 26, 20)); strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon) // DR-042 C6a: the only Aether sink (ability-damage upgrade) gets a visible, clickable button (was U-key // only). The Button element handles its own picking even though the HUD root Ignores clicks. // (Step 11: the Aether UPGRADE-DMG button was RETIRED with AbilityUpgradeRequest — the choice-of-3 // boon modal + the base meta-shop (Step 14) replace it.) root.Add(strip); } VisualElement ResourceChip(Sprite icon, Color tint, string initial, out Label num, float iconSize, int fontSize) { var chip = new VisualElement(); chip.style.flexDirection = FlexDirection.Row; chip.style.alignItems = Align.Center; chip.style.marginLeft = 9; chip.style.marginRight = 9; chip.pickingMode = PickingMode.Ignore; var ic = HudUi.Icon(icon, iconSize, tint); ic.style.marginRight = 6; chip.Add(ic); num = HudUi.Display(initial, fontSize, tint, TextAnchor.MiddleLeft); chip.Add(num); return chip; } void BuildPaletteRow(VisualElement root) { _paletteRow = new VisualElement(); _paletteRow.style.position = Position.Absolute; _paletteRow.style.bottom = 24; _paletteRow.style.left = 0; _paletteRow.style.right = 0; _paletteRow.style.flexDirection = FlexDirection.Row; _paletteRow.style.justifyContent = Justify.Center; _paletteRow.pickingMode = PickingMode.Ignore; // the row passes clicks through; its slots pick root.Add(_paletteRow); } void BuildHintBar(VisualElement root) { _hintBar = new VisualElement(); _hintBar.style.position = Position.Absolute; _hintBar.style.bottom = 138; _hintBar.style.left = 0; _hintBar.style.right = 0; _hintBar.style.flexDirection = FlexDirection.Row; _hintBar.style.justifyContent = Justify.Center; _hintBar.pickingMode = PickingMode.Ignore; _hintBar.style.display = DisplayStyle.None; root.Add(_hintBar); } void BuildDiscoveryChip(VisualElement root) { // Slice 1 HUD declutter: a subtle bottom-center chip teaching the build-mode toggle, shown only while // the palette is CLOSED at base. The glyph uses the text fallback ("Tab"/"Y") — no HudTheme sprite needed. bool pad = AimPresentation.Scheme == InputSchemeId.Gamepad; _buildDiscoveryChip = new VisualElement(); _buildDiscoveryChip.style.position = Position.Absolute; _buildDiscoveryChip.style.bottom = 28; _buildDiscoveryChip.style.left = 0; _buildDiscoveryChip.style.right = 0; _buildDiscoveryChip.style.flexDirection = FlexDirection.Row; _buildDiscoveryChip.style.justifyContent = Justify.Center; _buildDiscoveryChip.style.alignItems = Align.Center; _buildDiscoveryChip.pickingMode = PickingMode.Ignore; _buildDiscoveryChip.style.opacity = 0.6f; _buildDiscoveryChip.Add(HudUi.Glyph(null, pad ? "Y" : "Tab", 26)); var lbl = HudUi.Text("BUILD", 12, MenuUi.SubCol, TextAnchor.MiddleLeft); lbl.style.marginLeft = 5; _buildDiscoveryChip.Add(lbl); _buildDiscoveryChip.style.display = DisplayStyle.None; root.Add(_buildDiscoveryChip); } void BuildDowned(VisualElement root) { _downed = new VisualElement(); _downed.style.position = Position.Absolute; _downed.style.left = 0; _downed.style.right = 0; _downed.style.top = 0; _downed.style.bottom = 0; _downed.style.alignItems = Align.Center; _downed.style.justifyContent = Justify.Center; _downed.pickingMode = PickingMode.Ignore; var theme = HudTheme.Get(); if (theme != null && theme.Vignette != null) { _downed.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Vignette)); _downed.style.unityBackgroundImageTintColor = new Color(0.45f, 0f, 0f, 0.6f); _downed.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover)); } else { _downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f); } var downedCol = HudUi.Group(Align.Center); downedCol.Add(HudUi.Display("DOWNED", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter)); _downedText = HudUi.Text("RESPAWNING...", 24, new Color(1f, 0.75f, 0.7f), TextAnchor.MiddleCenter); _downedText.style.marginTop = 6; downedCol.Add(_downedText); _downed.Add(downedCol); _downed.style.display = DisplayStyle.None; root.Add(_downed); } void BuildRunBanner(VisualElement root) { _runBanner = new VisualElement(); _runBanner.style.position = Position.Absolute; _runBanner.style.left = 0; _runBanner.style.right = 0; _runBanner.style.top = 0; _runBanner.style.bottom = 0; _runBanner.style.alignItems = Align.Center; _runBanner.style.justifyContent = Justify.Center; _runBanner.pickingMode = PickingMode.Ignore; _runBanner.style.backgroundColor = new Color(0.02f, 0.03f, 0.05f, 0.55f); var col = HudUi.Group(Align.Center); _runBannerText = HudUi.Display("", 72, Color.white, TextAnchor.MiddleCenter); col.Add(_runBannerText); _runBannerSub = HudUi.Text("", 22, MenuUi.SubCol, TextAnchor.MiddleCenter); _runBannerSub.style.marginTop = 8; col.Add(_runBannerSub); // END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc. // SINGLE: PLAY AGAIN Continues as a fresh campaign (base+meta kept — the terminal save rolls // forward on stage). CO-OP (operator-locked): the honest exit is a clean teardown for everyone — // the host ends the session (each joiner's ConnectionWatchdog returns them to the menu with a // reason), a joiner just leaves. All self-guard on WorldLauncher.Busy. The row picks (Position) // even though the banner root Ignores. var btnRow = new VisualElement(); btnRow.style.flexDirection = FlexDirection.Row; btnRow.style.justifyContent = Justify.Center; btnRow.style.marginTop = 28; btnRow.pickingMode = PickingMode.Position; switch (WorldLauncher.LastMode) { case SessionMode.Host: btnRow.Add(MenuUi.Button("END SESSION — ALL TO MENU", WorldLauncher.TeardownToMenu)); break; case SessionMode.Join: btnRow.Add(MenuUi.Button("LEAVE TO MENU", WorldLauncher.TeardownToMenu)); break; default: var again = MenuUi.Button("PLAY AGAIN", () => WorldLauncher.StartSession(SessionMode.Single, null, SaveService.HasSave())); again.style.marginRight = 12; btnRow.Add(again); btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu)); break; } col.Add(btnRow); _runBanner.Add(col); _runBanner.style.display = DisplayStyle.None; root.Add(_runBanner); } 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 equipHeader = HudUi.Display("EQUIPMENT", 14, AetherCyan, TextAnchor.MiddleLeft); equipHeader.style.marginTop = 8; equipHeader.style.marginBottom = 4; _invPanel.Add(equipHeader); _equipList = new VisualElement(); _equipList.pickingMode = PickingMode.Ignore; _invPanel.Add(_equipList); var hint = HudUi.Text("I close - click item=equip / slot=unequip - G deposit", 11, MenuUi.SubCol, TextAnchor.MiddleLeft); hint.style.marginTop = 8; _invPanel.Add(hint); _invPanel.style.display = DisplayStyle.None; root.Add(_invPanel); } void AddInvRow(ushort itemId, string name, Color tint, int count, bool equippable) { var row = new VisualElement(); row.style.flexDirection = FlexDirection.Row; row.style.justifyContent = Justify.SpaceBetween; row.style.minWidth = 196; row.style.marginTop = 2; row.Add(HudUi.Text(name + (equippable ? " (equip)" : ""), 13, tint, TextAnchor.MiddleLeft)); row.Add(HudUi.Display("x" + count, 13, Color.white, TextAnchor.MiddleRight)); if (equippable) { row.pickingMode = PickingMode.Position; ushort id = itemId; row.RegisterCallback(_ => EquipSendSystem.Equip(id)); } else row.pickingMode = PickingMode.Ignore; _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 bool IsEquippable(bool haveDb, ItemDatabase db, ushort id) { if (!haveDb || !db.Value.IsCreated) return false; ref var b = ref db.Value.Value; return b.TryGetItem(id, out var def) && def.EquipSlot < EquipSlotId.Count; } static string SlotName(byte slot) { switch (slot) { case EquipSlotId.Weapon: return "Weapon"; case EquipSlotId.Armor: return "Armor"; case EquipSlotId.Trinket: return "Trinket"; case EquipSlotId.Tool: return "Tool"; default: return "Slot " + slot; } } void AddEquipRow(byte slot, string label, bool occupied) { var row = new VisualElement(); row.style.flexDirection = FlexDirection.Row; row.style.justifyContent = Justify.SpaceBetween; row.style.minWidth = 196; row.style.marginTop = 2; row.Add(HudUi.Text(label, 13, occupied ? AetherCyan : MenuUi.SubCol, TextAnchor.MiddleLeft)); if (occupied) { row.pickingMode = PickingMode.Position; byte s = slot; row.RegisterCallback(_ => EquipSendSystem.Unequip(s)); row.Add(HudUi.Text("unequip", 11, new Color(1f, 0.6f, 0.5f), TextAnchor.MiddleRight)); } else row.pickingMode = PickingMode.Ignore; _equipList.Add(row); } static Color ResourceTint(byte resId) => resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber; static Sprite ResourceSprite(HudTheme t, byte resId) { if (t == null) return null; return resId == ResourceId.Aether ? t.AetherIcon : resId == ResourceId.Biomass ? t.BioIcon : t.OreIcon; } // Conveyor facing (BuildPaletteState.Direction 0=+X,1=-X,2=+Z,3=-Z) → arrow rotation; the arrow art points up (+Z). static float FacingDegrees(byte dir) { switch (dir) { case 0: return 90f; // +X case 1: return 270f; // -X case 3: return 180f; // -Z default: return 0f; // +Z } } static Color PhaseColor(byte phase) { switch (phase) { case CyclePhase.Calm: return new Color(0.45f, 0.9f, 0.7f); case CyclePhase.Siege: return new Color(1f, 0.45f, 0.3f); default: return Color.white; } } static string PhaseLabel(byte phase) { switch (phase) { case CyclePhase.Calm: return "AT BASE"; case CyclePhase.Siege: return "UNDER SIEGE"; default: return ""; } } static string StructureName(byte type) { switch (type) { case StructureType.Turret: return "Turret"; case StructureType.Wall: return "Wall"; case StructureType.Pylon: return "Pylon"; case StructureType.Harvester: return "Harvester"; case StructureType.Fabricator: return "Fabricator"; case StructureType.Conveyor: return "Conveyor"; default: return "?"; } } // ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ==== static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]" : roomType == RoomTypeId.Elite ? "[ELITE]" : roomType == RoomTypeId.Reward ? "[REWARD]" : "[COMBAT]"; void UpdateBoonModal(BoonOffer offer, bool show, BlobAssetReference 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(_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(_ => RouteSendSystem.PickRoute(pick)); n.RegisterCallback(_ => n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f)); n.RegisterCallback(_ => n.style.backgroundColor = restBg); } return n; } // ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ---- void UpdateReadyPanel(bool show, RunInfo runInfo, int total, int ready, bool localReady, int launchSecs) { if (!show) { if (_readyPanel != null) _readyPanel.style.display = DisplayStyle.None; _readyShownFor = 0; return; } var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; if (!_readyPanelBuilt) { BuildReadyPanel(root); _readyPanelBuilt = true; } bool launching = runInfo.Lifecycle == RunLifecycle.Launching; int sig = 1 + ready + (total << 4) + (localReady ? 1 << 8 : 0) + (launchSecs << 9) + (launching ? 1 << 16 : 0); if (_readyShownFor != sig) { _readyTitle.text = launching ? "LAUNCHING IN " + launchSecs : "EXPEDITION — " + ready + "/" + Mathf.Max(total, 1) + " READY"; _readyTitle.style.color = launching ? new Color(1f, 0.9f, 0.4f) : new Color(0.55f, 0.85f, 1f); _readyBtn.text = launching ? "ABORT [T]" : localReady ? "UNREADY [T]" : "READY UP [T]"; _readyPipRow.Clear(); for (int i = 0; i < total; i++) { var pip = new VisualElement(); pip.style.width = 14; pip.style.height = 14; pip.style.marginLeft = 3; pip.style.marginRight = 3; MenuUi.Round(pip, 7f); pip.style.backgroundColor = i < ready ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 1f, 1f, 0.15f); _readyPipRow.Add(pip); } _readyShownFor = sig; } _readyPanel.style.display = DisplayStyle.Flex; } void BuildReadyPanel(VisualElement root) { _readyPanel = new VisualElement { pickingMode = PickingMode.Ignore }; _readyPanel.style.position = Position.Absolute; _readyPanel.style.left = 0; _readyPanel.style.right = 0; _readyPanel.style.bottom = 170; // clear of the build palette row + hint bar _readyPanel.style.alignItems = Align.Center; _readyPanel.style.display = DisplayStyle.None; var box = new VisualElement { pickingMode = PickingMode.Position }; 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 = 18; box.style.paddingRight = 18; box.style.paddingTop = 10; box.style.paddingBottom = 12; box.style.alignItems = Align.Center; _readyTitle = new Label("EXPEDITION"); _readyTitle.style.fontSize = 16; _readyTitle.style.unityFontStyleAndWeight = FontStyle.Bold; box.Add(_readyTitle); _readyPipRow = new VisualElement(); _readyPipRow.style.flexDirection = FlexDirection.Row; _readyPipRow.style.justifyContent = Justify.Center; _readyPipRow.style.marginTop = 6; _readyPipRow.style.marginBottom = 8; box.Add(_readyPipRow); _readyBtn = MenuUi.Button("READY UP [T]", ReadySendSystem.ToggleReady); box.Add(_readyBtn); _readyPanel.Add(box); root.Add(_readyPanel); } // ---- boss presence bar (Boss rooms only; red, top-center, under the macro banner) ---- void UpdateBossBar(bool alive, float hp, float max) { if (!alive || max <= 0f) { if (_bossPanel != null) _bossPanel.style.display = DisplayStyle.None; return; } var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; if (!_bossBarBuilt) { BuildBossBar(root); _bossBarBuilt = true; } HudUi.SetFill(_bossFill, Mathf.Clamp01(hp / max)); _bossText.text = "ALPHA HUSK " + Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(max); _bossPanel.style.display = DisplayStyle.Flex; } void BuildBossBar(VisualElement root) { _bossPanel = new VisualElement { pickingMode = PickingMode.Ignore }; _bossPanel.style.position = Position.Absolute; _bossPanel.style.left = 0; _bossPanel.style.right = 0; _bossPanel.style.top = 168; _bossPanel.style.alignItems = Align.Center; _bossPanel.style.display = DisplayStyle.None; var col = HudUi.Group(Align.Center); _bossText = HudUi.Display("ALPHA HUSK", 22, new Color(1f, 0.35f, 0.3f), TextAnchor.MiddleCenter); col.Add(_bossText); var bar = HudUi.Bar(420, 12, new Color(0.92f, 0.22f, 0.18f), out _bossFill); bar.style.marginTop = 4; col.Add(bar); _bossPanel.Add(col); root.Add(_bossPanel); } // ---- run-depth dots (visible through the whole run; the current room pulses bigger) ---- void UpdateRunDepth(RunInfo runInfo, bool haveRun) { bool show = haveRun && runInfo.RoomCount > 0 && (runInfo.Lifecycle == RunLifecycle.InRoom || runInfo.Lifecycle == RunLifecycle.RoomReward || runInfo.Lifecycle == RunLifecycle.RouteSelect); if (!show) { if (_depthPanel != null) _depthPanel.style.display = DisplayStyle.None; _depthShownFor = 0; return; } var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; if (!_depthBuilt) { _depthPanel = new VisualElement { pickingMode = PickingMode.Ignore }; _depthPanel.style.position = Position.Absolute; _depthPanel.style.left = 0; _depthPanel.style.right = 0; _depthPanel.style.top = 208; // below the macro cluster (goal/core) AND the boss bar (168) _depthPanel.style.flexDirection = FlexDirection.Row; _depthPanel.style.justifyContent = Justify.Center; root.Add(_depthPanel); _depthBuilt = true; } int sig = 1 + runInfo.CurrentRoom * 37 + runInfo.RoomCount * 3; if (_depthShownFor != sig) { _depthPanel.Clear(); for (int i = 0; i < runInfo.RoomCount; i++) { bool current = i == runInfo.CurrentRoom; bool done = i < runInfo.CurrentRoom; var dot = new VisualElement { pickingMode = PickingMode.Ignore }; float size = current ? 12f : 8f; dot.style.width = size; dot.style.height = size; dot.style.marginLeft = 3; dot.style.marginRight = 3; dot.style.alignSelf = Align.Center; MenuUi.Round(dot, size * 0.5f); dot.style.backgroundColor = current ? new Color(0.55f, 0.85f, 1f) : done ? new Color(0.55f, 0.85f, 1f, 0.55f) : new Color(1f, 1f, 1f, 0.16f); _depthPanel.Add(dot); } _depthShownFor = sig; } _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>().WithAll()) { 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(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>().WithAll()) { 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 pool, DynamicBuffer 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); } } }