e0c59ad663
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>
236 lines
12 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|