using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; namespace ProjectM.Client { /// /// Slice 1, Feature B — client-only enemy world-space HEALTH BARS (one pooled world-space Canvas per live Husk). /// Observe-only presentation in that reads replicated /// state and never mutates the sim or destroys a ghost. SELF-QUERIES enemies (Health + LocalTransform /// ) and self-detects the damage edge from a per-enemy LastHp stored on the bar entry: /// a decrease arms/refreshes that enemy's bar (sticky for , then fades). A bar /// stays permanently on below HP; when more than /// bars exist, distant ones (beyond of the local player) are hidden. /// Billboards to the main camera. Prunes its cache against its own seen-set EVERY frame (a despawn drops the bar). /// Extracted from CombatFeedbackSystem; owns its own FX-root + UI materials. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class EnemyHealthBarSystem : SystemBase { // CanvasGo == null => a tracking-only entry (enemy seen, not yet damaged, so no bar built). LastHp/MaxHp/Pos are // refreshed from the live query each frame; LastHp is the self-owned damage-edge source (was the core's _cache). struct HealthBarEntry { public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg; public float ShowTimer; public bool Visible; public float LastHp; public float MaxHp; public float3 Pos; // Track B: last values actually pushed to uGUI, so a bar that is merely showing pushes nothing. // CreateHealthBar seeds these to -1 (a struct would otherwise default them to 0, which is a // legitimate frac and would skip the first render of a fully-drained bar). public float LastFrac; public float LastAlpha; } const int HealthBarPoolLimit = 24; const float HealthBarShowDuration = 3f; const float HealthBarFadeDuration = 0.5f; const float HealthBarAlwaysOnThreshold = 0.25f; const float HealthBarWorldYOffset = 2.3f; readonly Dictionary _healthBars = new(); readonly List _barStale = new(); readonly List _barKeys = new(); readonly HashSet _seen = new(); // own enemy seen-set (per-frame prune) Material _barBgMat, _barFillMat; Transform _fxRoot; Entity _localPlayer = Entity.Null; protected override void OnStartRunning() { if (_fxRoot != null) return; _fxRoot = new GameObject("~EnemyHealthBarFX").transform; // Health-bar materials (UI/Default = always-included URP-compatible UI shader; per-instance Image.color carries alpha). Shader uiShader = Shader.Find("UI/Default") ?? Shader.Find("Sprites/Default"); _barBgMat = new Material(uiShader) { name = "HealthBarBg" }; _barFillMat = new Material(uiShader) { name = "HealthBarFill" }; } protected override void OnDestroy() { if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); if (_barBgMat != null) Object.Destroy(_barBgMat); if (_barFillMat != null) Object.Destroy(_barFillMat); foreach (var kv in _healthBars) if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo); } protected override void OnUpdate() { float dt = SystemAPI.Time.DeltaTime; var cam = Camera.main; // Predicted/physics jobs writing these must finish before this main-thread read. EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); // Local player (drives the pool-cap distance gate). _localPlayer = Entity.Null; float3 localPos = default; foreach (var (xf, entity) in SystemAPI.Query>() .WithAll().WithEntityAccess()) { _localPlayer = entity; localPos = xf.ValueRO.Position; } // Self-query enemies: track HP per enemy to self-detect the damage edge; a decrease arms/refreshes the bar. _seen.Clear(); foreach (var (health, xf, entity) in SystemAPI.Query, RefRO>().WithAll().WithEntityAccess()) { _seen.Add(entity); float cur = health.ValueRO.Current; float max = health.ValueRO.Max; float3 pos = xf.ValueRO.Position; bool existed = _healthBars.TryGetValue(entity, out var entry); bool damaged = existed && cur < entry.LastHp - 0.001f; // own damage edge (was core _cache prev.Hp) entry.LastHp = cur; entry.MaxHp = max; entry.Pos = pos; _healthBars[entity] = entry; if (damaged) ShowHealthBar(entity); // arm/refresh this enemy's bar on a damage edge } UpdateHealthBars(dt, cam, localPos); } // ---- Enemy Health Bars (Slice 1, Feature B) — pooled world-space Canvas, on-damage sticky + fade ---- void ShowHealthBar(Entity entity) { if (!_healthBars.TryGetValue(entity, out var entry) || entry.CanvasGo == null) entry = CreateHealthBar(entity); entry.ShowTimer = HealthBarShowDuration; if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; } _healthBars[entity] = entry; // struct — must re-assign } HealthBarEntry CreateHealthBar(Entity entity) { var go = new GameObject("EnemyHPBar"); if (_fxRoot != null) go.transform.SetParent(_fxRoot, false); var canvas = go.AddComponent(); canvas.renderMode = RenderMode.WorldSpace; canvas.sortingOrder = 5; // below the UITK HUD (50); above world geometry var rt = go.GetComponent(); rt.sizeDelta = new Vector2(1.2f, 0.14f); var bgGo = new GameObject("Bg"); bgGo.transform.SetParent(go.transform, false); var bgRt = bgGo.AddComponent(); bgRt.anchorMin = Vector2.zero; bgRt.anchorMax = Vector2.one; bgRt.offsetMin = bgRt.offsetMax = Vector2.zero; var bgImg = bgGo.AddComponent(); bgImg.material = _barBgMat; bgImg.color = new Color(0.05f, 0.05f, 0.06f, 0.82f); var fillGo = new GameObject("Fill"); fillGo.transform.SetParent(go.transform, false); var fillRt = fillGo.AddComponent(); fillRt.anchorMin = Vector2.zero; fillRt.anchorMax = Vector2.one; fillRt.offsetMin = new Vector2(0.02f, 0.02f); fillRt.offsetMax = new Vector2(-0.02f, -0.02f); var fillImg = fillGo.AddComponent(); fillImg.material = _barFillMat; fillImg.color = new Color(0.88f, 0.22f, 0.14f, 1f); fillImg.type = UnityEngine.UI.Image.Type.Simple; // a sprite-less UI Image ignores fillAmount (it draws a full quad) -> fillImg.raycastTarget = false; // the bar empties by sizing the fill RectTransform (anchorMax.x = frac) in UpdateHealthBars go.SetActive(false); _healthBars.TryGetValue(entity, out var prev); // preserve tracking (LastHp/MaxHp/Pos) recorded by the scan loop var entry = new HealthBarEntry { CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false, LastHp = prev.LastHp, MaxHp = prev.MaxHp, Pos = prev.Pos, LastFrac = -1f, LastAlpha = -1f // force the first visual push (a real frac/alpha is never negative) }; _healthBars[entity] = entry; return entry; } // Per-frame: prune dead bars (own seen-set), pool-cap by distance, billboard + fade. void UpdateHealthBars(float dt, Camera cam, float3 localPlayerPos) { if (_healthBars.Count > 0) { _barStale.Clear(); foreach (var kv in _healthBars) if (!_seen.Contains(kv.Key)) _barStale.Add(kv.Key); for (int i = 0; i < _barStale.Count; i++) { var e2 = _barStale[i]; if (_healthBars[e2].CanvasGo != null) Object.Destroy(_healthBars[e2].CanvasGo); _healthBars.Remove(e2); } } if (_healthBars.Count == 0) return; // Cap keys on the number of BUILT bars (not the tracking-only entries), matching the original threshold. int createdBars = 0; foreach (var kv in _healthBars) if (kv.Value.CanvasGo != null) createdBars++; bool capBars = _localPlayer != Entity.Null && createdBars > HealthBarPoolLimit; _barKeys.Clear(); foreach (var k in _healthBars.Keys) _barKeys.Add(k); for (int i = 0; i < _barKeys.Count; i++) { var key = _barKeys[i]; var entry = _healthBars[key]; if (entry.CanvasGo == null) continue; // tracking-only (undamaged) — no bar built yet float frac = entry.MaxHp > 0f ? math.saturate(entry.LastHp / entry.MaxHp) : 1f; bool alwaysOn = frac < HealthBarAlwaysOnThreshold; if (capBars && math.lengthsq(entry.Pos - localPlayerPos) > FeelConfig.HealthBarMaxDistSq) { if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; } _healthBars[key] = entry; continue; } if (!alwaysOn) entry.ShowTimer -= dt; bool shouldShow = alwaysOn || entry.ShowTimer > -HealthBarFadeDuration; if (shouldShow) { if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; } if (cam != null) { entry.CanvasGo.transform.position = (Vector3)entry.Pos + Vector3.up * HealthBarWorldYOffset; entry.CanvasGo.transform.rotation = cam.transform.rotation; // billboard } float alpha = (!alwaysOn && entry.ShowTimer < 0f) ? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f; // Track B: these ran unconditionally for every visible bar every frame. Writing // anchorMax triggers OnRectTransformDimensionsChange and an Image.color write dirties the // canvas — so a bar that is merely SHOWING (not changing) used to keep re-laying-out uGUI. // Epsilon-gated: a bar only pushes when its fill or fade actually moved. if (entry.Fill != null && (math.abs(frac - entry.LastFrac) > 0.002f || math.abs(alpha - entry.LastAlpha) > 0.002f)) { var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c; entry.Fill.rectTransform.anchorMax = new Vector2(frac, 1f); if (entry.Bg != null) { var bgc = entry.Bg.color; bgc.a = 0.82f * alpha; entry.Bg.color = bgc; } entry.LastFrac = frac; entry.LastAlpha = alpha; } } else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; } _healthBars[key] = entry; } } } }