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))] 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 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 float ExpeditionRegionXMin = RegionMath.RegionBoundaryX; // 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 line VisualElement _banner; Label _phaseText, _locationText; // Demo polish: the clickable READY panel (Staging/Launching). VisualElement _readyPanel, _readyPipRow; Button _readyBtn; Label _readyTitle; bool _readyPanelBuilt; int _readyShownFor; // (ready, total, localReady, secs, launching) rebuild signature // 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; // resources Label _aetherNum, _oreNum, _bioNum; // build palette + hints VisualElement _paletteRow, _hintBar, _buildDiscoveryChip; bool _paletteBuilt, _hintBuilt; 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 banner: run-lifecycle header (the siege/cycle machinery is retired — LANTERN purge) ---- bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); bool onRun = haveRun && runInfo.Lifecycle != RunLifecycle.Staging; if (haveRun) { var col = onRun ? new Color(1f, 0.8f, 0.4f) : new Color(0.45f, 0.9f, 0.7f); _phaseText.text = onRun ? "ON EXPEDITION" : "AT BASE"; _phaseText.style.color = col; _banner.style.borderBottomColor = col; RetintPanel(_banner, PanelDark); } else { _phaseText.text = ""; } // ---- Location line (banner sub-line) — Step 14: driven by the replicated RunInfo lifecycle FSM ---- 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) { switch (runInfo.Lifecycle) { case RunLifecycle.Staging: // The READY panel (bottom-center) owns the action + N/M count; the top line frames intent. _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 { _locationText.text = ""; } // The clickable READY panel (Staging/Launching). Counts are the replicated send-to-all PlayerReady flags. int rTotal = 0, rReady = 0; bool localReady = false; int launchSecs = 0; bool readyShow = haveRun && (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); // ---- Resources (feed palette affordability) ---- int aether = 0, ore = 0, bio = 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; } } _aetherNum.text = aether.ToString(); _oreNum.text = ore.ToString(); _bioNum.text = bio.ToString(); // ---- Threat readout (top-right) — hidden entirely with zero husks; its reappearance is the cue ---- bool showThreat = huskCount > 0; _threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None; if (showThreat) { float intensity = Mathf.Clamp01(huskCount / 30f); Color tc = Color.Lerp(ThreatWarm, BlightRed, intensity); _threatNum.text = huskCount.ToString(); _threatNum.style.color = tc; _threatIcon.style.unityBackgroundImageTintColor = tc; RetintPanel(_threatPanel, 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; if (!_hintBuilt || _hintScheme != scheme) RebuildHints(scheme); _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 || haveRun) ? 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; } // LANTERN purge: the automation buildables are deleted; Pylon stays hidden from the build palette (cosmetic-only). static bool IsPaletteType(byte type) => type != StructureType.Pylon; 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) { _hintBar.Clear(); 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"); AddHint(pad ? theme?.PadExit : null, pad ? "MENU" : "ESC", "EXIT"); _hintScheme = scheme; _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); } 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); macro.Add(_banner); _locationText = HudUi.Text("", 15, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter); _locationText.style.marginTop = 5; macro.Add(_locationText); 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)); // 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 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; } static string StructureName(byte type) { switch (type) { case StructureType.Wall: return "Wall"; case StructureType.Pylon: return "Pylon"; 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]"; // ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind // to the authoritative RouteOpt* bytes) ---- // ---- 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; } } }