Files
Project-M/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs
T
kronic e0c59ad663 Perf: pool one-shot SFX + authored VFX, cut per-frame presentation allocation
Track B. All 21 one-shot cues funnelled through FeedbackFx.PlayClip ->
AudioSource.PlayClipAtPoint, which allocates a GameObject + AudioSource per call
and schedules a delayed Destroy — ~20-33 times a second in light combat. New
OneShotAudioPool is a 32-voice 3D ring behind an UNCHANGED PlayClip signature, so
all 20 consuming call sites are untouched.

Parity is the whole game here: PlayClipAtPoint sets spatialBlend = 1 explicitly (a
fresh AudioSource is 2D) and leaves the rest at stock defaults. Two deliberate
divergences, both forced by the voices being long-lived: playOnAwake = false, and
dopplerLevel = 0 because a pooled voice TELEPORTS between events and would
otherwise pitch-bend. Root is DontDestroyOnLoad (WorldLauncher does
LoadScene(Single) while the client world is alive) with a SubsystemRegistration
reset, or session two rents destroyed voices.

Authored impact VFX are pooled per prefab instead of Instantiate/Destroy per hit:
components cached per INSTANCE (refs are instance-scoped), main.stopAction forced
to None (a prefab set to Destroy silently drains the pool), instances filled under
an inactive root so Awake/Start never run — which is what makes the DestroyImmediate
in StripCosmetic safe — ps.Clear before Play, TrailRenderer.Clear after the
reposition, and a Rented flag as the at-most-once guard against a double Return
aliasing one instance to two callers.

Per-frame allocation: the slash-arc and enemy-wedge mesh builders each allocated
four arrays on every call (up to twice a frame, and once per winding enemy); HUD
and ability-bar labels rebuilt their strings every frame; damage-number fades
rewrote TextMesh vertex colours every frame; health bars pushed uGUI writes
unconditionally; two systems played back an empty EntityCommandBuffer (a
structural-change sync point) every frame.

Also closes an AudioClip leak across all seven clip-owning systems: an
AudioClip.Create'd clip is a standalone UnityEngine.Object, so destroying a
system's FX root left it alive (MusicSystem ~6.8 MB, AmbientAudioSystem ~2 MB per
client-world teardown).

CombatFeedbackSystem's TryHold call sites go with this commit because they share
the file; the camera-side removal lands in the next one.

Verified live: PlayClipAtPoint's "One shot audio" GameObject never appears again
across 270 frames of combat with kills; the VFX pool fills to its retain cap and
stabilises; real cues route through the ring. 304/304 EditMode green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:02:50 -07:00

236 lines
12 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;
// 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<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,
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;
}
}
}
}