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, rebuilt on UI Toolkit so it reads in the same Aether-cyan visual language as the /// menu / pause / settings screens ( / ). A managed presentation /// SystemBase () 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 clicks meant for the game world — only the build-palette buttons pick. Presentation only /// (client world, no simulation). The palette buttons set (client-local UI /// state), which BuildSendSystem turns into a ground ghost + click-to-place. The visual tree is built on the /// first OnUpdate where the UIDocument's root exists (giving the panel a frame to initialise its PanelSettings). /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class HudSystem : SystemBase { GameObject _hudGo; UIDocument _doc; bool _built; VisualElement _healthFill, _cooldownFill, _goalFill, _downed, _paletteRow; Label _healthText, _threatText, _phaseText, _resourceText, _locationText, _goalText; bool _paletteBuilt; readonly Dictionary _palette = new(); EntityQuery _huskQuery; struct PaletteItem { public VisualElement Root; public Label Cost; public int CostAmount; } 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; } bool haveTick = SystemAPI.TryGetSingleton(out var nt); int huskCount = _huskQuery.CalculateEntityCount(); // ---- Macro loop: phase + cycle + countdown ---- bool haveCycle = SystemAPI.TryGetSingleton(out var cyc); if (haveCycle) { var endTick = new NetworkTick(cyc.PhaseEndTick); string detail; if (cyc.Phase == CyclePhase.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 = ""; _phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : ""); _phaseText.style.color = PhaseColor(cyc.Phase); } else { _phaseText.text = ""; } // ---- Location + gate hint ---- var cam = Camera.main; bool onExpedition = cam != null && cam.transform.position.x > 500f; _locationText.text = onExpedition ? "ON EXPEDITION - return through the gate" : "AT BASE - deploy through the gate when you're ready"; _locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f); // ---- Goal ---- if (SystemAPI.TryGetSingleton(out var goal)) { float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f; HudUi.SetFill(_goalFill, gfrac); _goalText.text = "GOAL " + goal.Charge + " / " + goal.Target; } // ---- Resources (Ore feeds the 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; } } _resourceText.text = "AETHER " + aether + " ORE " + ore + " BIO " + bio; // ---- Build palette (lazy-built once the catalog has streamed; hidden off-base) ---- UpdatePalette(ore, onExpedition); // ---- 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; if (!found) { _downed.style.display = DisplayStyle.None; return; } float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f; 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) + (shielded ? " SHIELDED" : ""); HudUi.SetFill(_cooldownFill, cdFrac); _threatText.text = "HUSKS " + huskCount; _downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None; } void UpdatePalette(int ore, 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); _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; bool affordable = ore >= item.CostAmount; bool selected = BuildPaletteState.Selected == kv.Key; item.Root.style.opacity = affordable ? 1f : 0.45f; item.Cost.style.color = affordable ? new Color(0.7f, 0.95f, 0.8f) : new Color(1f, 0.5f, 0.4f); MenuUi.Border(item.Root, selected ? MenuUi.Accent : new Color(1f, 1f, 1f, 0.08f), selected ? 2 : 1); item.Root.style.backgroundColor = selected ? new Color(0.16f, 0.26f, 0.32f, 0.95f) : new Color(0.09f, 0.11f, 0.15f, 0.92f); } } void AddPaletteItem(byte type, int cost) { if (type == 0 || _palette.ContainsKey(type)) return; var root = new VisualElement(); root.style.width = 92; root.style.marginLeft = 4; root.style.marginRight = 4; root.style.paddingTop = 6; root.style.paddingBottom = 6; root.style.alignItems = Align.Center; root.style.backgroundColor = new Color(0.09f, 0.11f, 0.15f, 0.92f); root.pickingMode = PickingMode.Position; MenuUi.Round(root, 6); MenuUi.Border(root, new Color(1f, 1f, 1f, 0.08f), 1); var nameLabel = HudUi.Text(StructureName(type), 13, MenuUi.TextCol, TextAnchor.MiddleCenter); var costLabel = HudUi.Text(cost + " ORE", 12, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter); costLabel.style.marginTop = 2; root.Add(nameLabel); root.Add(costLabel); 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 }; } // ---- UITK construction ---- void BuildTree(VisualElement root) { 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 // Health + cooldown (bottom-left). var vitals = HudUi.Group(); vitals.style.position = Position.Absolute; vitals.style.left = 40; vitals.style.bottom = 40; var cdBar = HudUi.Bar(440, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill); cdBar.style.marginBottom = 6; var hpBar = HudUi.Bar(440, 40, new Color(0.25f, 0.9f, 0.5f), out _healthFill); _healthText = HudUi.Text("100 / 100", 22, 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); vitals.Add(cdBar); vitals.Add(hpBar); root.Add(vitals); // Threat (top-right). _threatText = HudUi.Text("HUSKS 0", 30, new Color(1f, 0.62f, 0.4f), TextAnchor.UpperRight); _threatText.style.position = Position.Absolute; _threatText.style.right = 40; _threatText.style.top = 28; _threatText.style.width = 380; root.Add(_threatText); // Macro stack (top-center). var macro = HudUi.Group(Align.Center); macro.style.position = Position.Absolute; macro.style.top = 22; macro.style.left = 0; macro.style.right = 0; _phaseText = HudUi.Text("", 30, new Color(0.55f, 0.9f, 1f), TextAnchor.MiddleCenter); _resourceText = HudUi.Text("", 22, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter); _resourceText.style.marginTop = 4; _locationText = HudUi.Text("", 18, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter); _locationText.style.marginTop = 4; var goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill); goalBar.style.marginTop = 8; _goalText = HudUi.Text("GOAL 0 / 10", 13, Color.white, TextAnchor.MiddleCenter); _goalText.style.position = Position.Absolute; _goalText.style.left = 0; _goalText.style.right = 0; _goalText.style.top = 0; _goalText.style.bottom = 0; goalBar.Add(_goalText); macro.Add(_phaseText); macro.Add(_resourceText); macro.Add(_locationText); macro.Add(goalBar); root.Add(macro); // Build palette row (bottom-center). The row passes clicks through; its buttons pick. _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; root.Add(_paletteRow); // Downed overlay. _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.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f); _downed.style.alignItems = Align.Center; _downed.style.justifyContent = Justify.Center; _downed.pickingMode = PickingMode.Ignore; _downed.Add(HudUi.Text("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter)); _downed.style.display = DisplayStyle.None; root.Add(_downed); } 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 "?"; } } } }