using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; using UnityEngine; using UnityEngine.UI; namespace ProjectM.Client { /// /// Client-only screen HUD. A managed presentation SystemBase () that builds /// a uGUI overlay canvas in code and drives it from the LOCAL player ghost each frame: a health bar /// (Health / EffectiveCharacterStats.MaxHealth), an ability-cooldown bar (AbilityCooldown vs NetworkTime /// ServerTick + EffectiveAbilityStats.CooldownTicks), a live Husk threat count, and a DOWNED/RESPAWNING overlay /// (the derived gate). Presentation only — no simulation, client world only. Bars are /// RawImages over Texture2D.whiteTexture (always available; the fill width is the RectTransform's /// anchorMax.x), so the HUD needs no sprite assets — only a resolved builtin font for the labels. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class HudSystem : SystemBase { Canvas _canvas; RectTransform _healthFill; RectTransform _cooldownFill; Text _healthText; Text _threatText; Text _phaseText; Text _resourceText; Text _locationText; RectTransform _goalFill; Text _goalText; GameObject _respawnOverlay; EntityQuery _huskQuery; protected override void OnCreate() { _huskQuery = GetEntityQuery(ComponentType.ReadOnly()); } protected override void OnStartRunning() { if (_canvas == null) BuildHud(); } protected override void OnDestroy() { if (_canvas != null) Object.Destroy(_canvas.gameObject); } protected override void OnUpdate() { if (_canvas == null) return; bool haveTick = SystemAPI.TryGetSingleton(out var nt); // Macro-loop HUD (phase + cycle + countdown + location), read before the per-player early-out so it persists pre-spawn. bool haveCycle = SystemAPI.TryGetSingleton(out var cyc); if (_phaseText != null && haveCycle) { var endTick = new NetworkTick(cyc.PhaseEndTick); string detail; if (cyc.Phase == CyclePhase.Defend) detail = "WAVE " + cyc.WaveNumber + " - " + _huskQuery.CalculateEntityCount() + " HUSKS"; else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick)) detail = (endTick.TicksSince(nt.ServerTick) / 60) + "s"; else detail = ""; _phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : "") + " CYCLE " + cyc.CycleNumber; _phaseText.color = PhaseColor(cyc.Phase); } else if (_phaseText != null) { _phaseText.text = ""; } if (_locationText != null) { var cam = Camera.main; bool onExpedition = cam != null && cam.transform.position.x > 500f; _locationText.text = onExpedition ? "ON EXPEDITION - return through the gate" : "AT BASE" + (haveCycle && cyc.Phase == CyclePhase.Expedition ? " - step into the gate to deploy" : ""); _locationText.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f); } if (_goalFill != null && SystemAPI.TryGetSingleton(out var goal)) { float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f; SetFill(_goalFill, gfrac); if (_goalText != null) _goalText.text = "GOAL " + goal.Charge + " / " + goal.Target; } if (_resourceText != null) { string res = ""; if (SystemAPI.TryGetSingletonEntity(out var ledgerE)) { var buf = SystemAPI.GetBuffer(ledgerE); int aether = 0, ore = 0, bio = 0; 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; } res = "AETHER " + aether + " ORE " + ore + " BIO " + bio; } _resourceText.text = res; } 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); // Cooldown fraction via wrap-safe NetworkTick compare (raw uint subtraction is unsafe across // tick wraparound — the project convention, mirroring AbilityFireSystem/EnemyAISystem). 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; } _canvas.enabled = found || haveCycle; if (!found) return; float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f; SetFill(_healthFill, frac); var hc = _healthFill.GetComponent(); if (hc != null) hc.color = 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); if (_healthText != null) _healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp) + (shielded ? " SHIELDED" : ""); SetFill(_cooldownFill, cdFrac); if (_threatText != null) _threatText.text = "HUSKS " + _huskQuery.CalculateEntityCount(); _respawnOverlay.SetActive(dead); } static void SetFill(RectTransform fill, float frac) { if (fill == null) return; var max = fill.anchorMax; max.x = Mathf.Clamp01(frac); fill.anchorMax = max; } // ---- uGUI construction (code-built; no prefab/sprite assets) ---- void BuildHud() { var go = new GameObject("~HUD"); _canvas = go.AddComponent(); _canvas.renderMode = RenderMode.ScreenSpaceOverlay; _canvas.sortingOrder = 100; var scaler = go.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920, 1080); go.AddComponent(); var font = GetFont(); // Health bar (bottom-left). var hpBg = MakeBar("HealthBg", _canvas.transform, new Color(0f, 0f, 0f, 0.6f), new Vector2(40, 46), new Vector2(440, 40)); _healthFill = MakeFill("HealthFill", hpBg, new Color(0.25f, 0.9f, 0.5f)); _healthText = MakeText("HealthText", hpBg, "100 / 100", 24, TextAnchor.MiddleCenter, Color.white, font); // Cooldown bar (just above health). var cdBg = MakeBar("CooldownBg", _canvas.transform, new Color(0f, 0f, 0f, 0.55f), new Vector2(40, 92), new Vector2(440, 14)); _cooldownFill = MakeFill("CooldownFill", cdBg, new Color(0.4f, 0.8f, 1f)); // Threat count (top-right). _threatText = MakeText("ThreatText", _canvas.transform, "HUSKS 0", 30, TextAnchor.UpperRight, new Color(1f, 0.62f, 0.4f), font); var trt = _threatText.rectTransform; trt.anchorMin = new Vector2(1, 1); trt.anchorMax = new Vector2(1, 1); trt.pivot = new Vector2(1, 1); trt.anchoredPosition = new Vector2(-40, -30); trt.sizeDelta = new Vector2(380, 50); // Cycle phase + number (top-center). _phaseText = MakeText("PhaseText", _canvas.transform, "EXPEDITION CYCLE 1", 34, TextAnchor.UpperCenter, new Color(0.55f, 0.9f, 1f), font); var prt = _phaseText.rectTransform; prt.anchorMin = new Vector2(0.5f, 1f); prt.anchorMax = new Vector2(0.5f, 1f); prt.pivot = new Vector2(0.5f, 1f); prt.anchoredPosition = new Vector2(0, -24); prt.sizeDelta = new Vector2(600, 50); // Resource ledger counts (top-center, below phase). _resourceText = MakeText("ResourceText", _canvas.transform, "", 24, TextAnchor.UpperCenter, new Color(0.7f, 0.95f, 0.8f), font); var rrt = _resourceText.rectTransform; rrt.anchorMin = new Vector2(0.5f, 1f); rrt.anchorMax = new Vector2(0.5f, 1f); rrt.pivot = new Vector2(0.5f, 1f); rrt.anchoredPosition = new Vector2(0, -64); rrt.sizeDelta = new Vector2(600, 40); // Location + gate hint (top-center, below resources). _locationText = MakeText("LocationText", _canvas.transform, "", 22, TextAnchor.UpperCenter, new Color(0.6f, 0.85f, 1f), font); var lrt = _locationText.rectTransform; lrt.anchorMin = new Vector2(0.5f, 1f); lrt.anchorMax = new Vector2(0.5f, 1f); lrt.pivot = new Vector2(0.5f, 1f); lrt.anchoredPosition = new Vector2(0, -96); lrt.sizeDelta = new Vector2(760, 36); // Goal progress bar (top-center, below the location line). var goalBg = MakeBar("GoalBg", _canvas.transform, new Color(0f, 0f, 0f, 0.55f), Vector2.zero, new Vector2(360, 16)); goalBg.anchorMin = new Vector2(0.5f, 1f); goalBg.anchorMax = new Vector2(0.5f, 1f); goalBg.pivot = new Vector2(0.5f, 1f); goalBg.anchoredPosition = new Vector2(0, -126); goalBg.sizeDelta = new Vector2(360, 16); _goalFill = MakeFill("GoalFill", goalBg, new Color(0.8f, 0.6f, 1f)); _goalText = MakeText("GoalText", goalBg, "GOAL 0 / 10", 15, TextAnchor.MiddleCenter, Color.white, font); // Downed / respawning overlay (full screen, toggled by Dead). _respawnOverlay = new GameObject("RespawnOverlay", typeof(RectTransform)); _respawnOverlay.transform.SetParent(_canvas.transform, false); var ov = _respawnOverlay.AddComponent(); ov.texture = Texture2D.whiteTexture; ov.color = new Color(0.35f, 0f, 0f, 0.35f); ov.raycastTarget = false; Stretch((RectTransform)_respawnOverlay.transform); var rtext = MakeText("RespawnText", _respawnOverlay.transform, "DOWNED - RESPAWNING", 56, TextAnchor.MiddleCenter, new Color(1f, 0.45f, 0.4f), font); Stretch(rtext.rectTransform); _respawnOverlay.SetActive(false); } static RectTransform MakeBar(string name, Transform parent, Color color, Vector2 anchoredPos, Vector2 size) { var go = new GameObject(name, typeof(RectTransform)); go.transform.SetParent(parent, false); var img = go.AddComponent(); img.texture = Texture2D.whiteTexture; img.color = color; img.raycastTarget = false; var rt = (RectTransform)go.transform; rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.zero; rt.pivot = Vector2.zero; rt.anchoredPosition = anchoredPos; rt.sizeDelta = size; return rt; } static RectTransform MakeFill(string name, RectTransform parent, Color color) { var go = new GameObject(name, typeof(RectTransform)); go.transform.SetParent(parent, false); var img = go.AddComponent(); img.texture = Texture2D.whiteTexture; img.color = color; img.raycastTarget = false; var rt = (RectTransform)go.transform; rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.pivot = Vector2.zero; rt.offsetMin = new Vector2(3, 3); rt.offsetMax = new Vector2(-3, -3); return rt; } static Text MakeText(string name, Transform parent, string text, int size, TextAnchor anchor, Color color, Font font) { var go = new GameObject(name, typeof(RectTransform)); go.transform.SetParent(parent, false); var t = go.AddComponent(); t.text = text; t.font = font; t.fontSize = size; t.alignment = anchor; t.color = color; t.horizontalOverflow = HorizontalWrapMode.Overflow; t.verticalOverflow = VerticalWrapMode.Overflow; t.raycastTarget = false; Stretch(t.rectTransform); return t; } static void Stretch(RectTransform rt) { rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero; } static Font GetFont() { Font f = Resources.GetBuiltinResource("LegacyRuntime.ttf"); if (f == null) f = Resources.GetBuiltinResource("Arial.ttf"); if (f == null) f = Font.CreateDynamicFontFromOSFont(new[] { "Arial", "Liberation Sans", "DejaVu Sans" }, 28); return f; } static Color PhaseColor(byte phase) { switch (phase) { case CyclePhase.Expedition: return new Color(0.45f, 0.85f, 1f); case CyclePhase.Defend: return new Color(1f, 0.5f, 0.3f); case CyclePhase.Build: return new Color(0.45f, 0.95f, 0.6f); default: return Color.white; } } static string PhaseLabel(byte phase) { switch (phase) { case CyclePhase.Expedition: return "EXPEDITION"; case CyclePhase.Defend: return "DEFEND"; case CyclePhase.Build: return "BUILD"; default: return ""; } } } }