using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; 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 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; readonly List _pips = new(); // resources Label _aetherNum, _oreNum, _bioNum; // build palette + hints VisualElement _paletteRow, _hintBar, _facingArrow; bool _paletteBuilt, _hintBuilt, _hintConveyor; byte _hintScheme = 255; readonly Dictionary _palette = new(); // overlays VisualElement _vignette, _downed; 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 haveCycle = SystemAPI.TryGetSingleton(out var cyc); bool siege = haveCycle && cyc.Phase == CyclePhase.Siege; if (haveCycle) { var endTick = new NetworkTick(cyc.PhaseEndTick); string detail; if (siege) detail = "WAVE " + cyc.WaveNumber + " - " + huskCount + " HUSKS"; else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick)) detail = "INCURSION IN " + (endTick.TicksSince(nt.ServerTick) / 60 + 1) + "s"; else detail = ""; var col = PhaseColor(cyc.Phase); _phaseText.text = 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 + gate hint (banner sub-line) ---- var cam = Camera.main; bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin; _locationText.text = onExpedition ? "ON EXPEDITION - carve the frontier, then return" : siege ? "DEFEND THE BASE - hold the line" : "MINE THE CRYSTALS - any attack harvests Ore, then BUILD"; _locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f); // ---- 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; 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 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 buildActive = BuildPaletteState.Active && !onExpedition && _paletteBuilt; 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; } // ---- 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; _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); } } 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++) AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId); _paletteBuilt = true; } if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; } _paletteRow.style.display = onExpedition ? DisplayStyle.None : DisplayStyle.Flex; 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); 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); _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); 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)); 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 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); } _downed.Add(HudUi.Display("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter)); _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; } // 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 "?"; } } } }