6379f5d897
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>
222 lines
11 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|