Files
Project-M/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs
T
kronic 6379f5d897 Hygiene B5: split HudSystem + CombatFeedbackSystem god-objects
Extract 7 sibling PresentationSystemGroup systems (client-only, observe-only), faithfully relocating methods+fields so behavior is preserved by construction:
- HudSystem (2129->1588L): BoonModalHudSystem, RouteMapHudSystem, MetaShopHudSystem, ClassPrepPortalHudSystem — each owns its own runtime UIDocument (MenuUi.LoadPanelSettings + own sortingOrder + EnsureEventSystem), the proven EnemyMarkerSystem/OnboardingSystem pattern; no shared root, no new static bridge.
- CombatFeedbackSystem (1300->914L): RoomPortalBeaconSystem, EnemyHealthBarSystem, EnemyDangerTelegraphSystem — each owns its FX-root + mats (via FeedbackFx), self-queries enemies + self-detects its edge (health-bar LastHp; danger _prevWindup), prunes its caches each frame.

Verified: compiles clean (0 errors), 466/466 EditMode tests pass, Play world-creation clean (no ComponentSystemSorter cycle, no OnCreate exception, 0 console errors). NOTE: the final VISUAL smoke (panels appear at the right lifecycle; enemy health bars / danger telegraphs / portal beacon render; buttons live) needs a FOCUSED Play pass — the play-mode transition throttles while Unity is unfocused, so I could not drive live frames headlessly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 14:00:55 -07:00

222 lines
11 KiB
C#

using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Slice 1, Feature B — client-only enemy world-space HEALTH BARS (one pooled world-space Canvas per live Husk).
/// Observe-only presentation <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> that reads replicated
/// state and never mutates the sim or destroys a ghost. SELF-QUERIES enemies (<c>Health</c> + <c>LocalTransform</c>
/// <see cref="EnemyTag"/>) and self-detects the damage edge from a per-enemy <c>LastHp</c> stored on the bar entry:
/// a decrease arms/refreshes that enemy's bar (sticky for <see cref="HealthBarShowDuration"/>, then fades). A bar
/// stays permanently on below <see cref="HealthBarAlwaysOnThreshold"/> HP; when more than <see cref="HealthBarPoolLimit"/>
/// bars exist, distant ones (beyond <see cref="FeelConfig.HealthBarMaxDistSq"/> 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.
/// </summary>
[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;
}
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<Entity, HealthBarEntry> _healthBars = new();
readonly List<Entity> _barStale = new();
readonly List<Entity> _barKeys = new();
readonly HashSet<Entity> _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<Health>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
// Local player (drives the pool-cap distance gate).
_localPlayer = Entity.Null;
float3 localPos = default;
foreach (var (xf, entity) in SystemAPI.Query<RefRO<LocalTransform>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>().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<Health>, RefRO<LocalTransform>>().WithAll<EnemyTag>().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>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.sortingOrder = 5; // below the UITK HUD (50); above world geometry
var rt = go.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(1.2f, 0.14f);
var bgGo = new GameObject("Bg");
bgGo.transform.SetParent(go.transform, false);
var bgRt = bgGo.AddComponent<RectTransform>();
bgRt.anchorMin = Vector2.zero; bgRt.anchorMax = Vector2.one;
bgRt.offsetMin = bgRt.offsetMax = Vector2.zero;
var bgImg = bgGo.AddComponent<UnityEngine.UI.Image>();
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<RectTransform>();
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<UnityEngine.UI.Image>();
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
};
_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;
if (entry.Fill != null) { 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 c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; }
}
else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; }
_healthBars[key] = entry;
}
}
}
}