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>
274 lines
14 KiB
C#
274 lines
14 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
|
|
namespace ProjectM.Client
|
|
{
|
|
/// <summary>
|
|
/// 07-21 UI rework (operator: "I want to see the cds of socketed abilities + dash") — the bottom-center
|
|
/// ABILITY BAR: one slot per socket (keys 1-4; socket 0 doubles as RMB) + the dash (SHIFT). Each slot shows
|
|
/// the Spark's initials + name (from the AbilityDatabase blob), a bottom-anchored dark overlay that DRAINS
|
|
/// as the cooldown recovers, a seconds countdown, and a brief ready-flash on the ready edge. Replaces the
|
|
/// old single-socket-0 charge strip in HudSystem's vitals block.
|
|
/// B5 sibling pattern: OWN UIDocument (~HUDAbilityBar, MenuUi.LoadPanelSettings(), sortingOrder 49 — under
|
|
/// HudSystem's 50), observe-only <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>, tree
|
|
/// built once rootVisualElement != null, root pickingMode = Ignore. Cooldown math = the HudSystem idiom:
|
|
/// remaining = NextFire.TicksSince(nt.ServerTick) vs EffectiveSocketStats.CooldownTicks (the OWNER's own
|
|
/// cooldowns ride the PREDICTED tick — this is local-player state, unlike the zone telegraph's interpolated
|
|
/// read). Dash = DashCooldown.NextTick vs TuningConfig.DashCooldownTicks with the Defaults() fallback
|
|
/// (review wf_98bf1268: the dev TuningConfig singleton is editor-only — release must match Defaults()).
|
|
/// </summary>
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
|
public partial class AbilityBarSystem : SystemBase
|
|
{
|
|
const int SlotCount = SocketId.Count + 1; // 4 sockets + dash
|
|
const int DashSlot = SocketId.Count;
|
|
const float k_ReadyFlashSeconds = 0.28f;
|
|
|
|
GameObject _hudGo;
|
|
UIDocument _doc;
|
|
bool _built;
|
|
|
|
readonly VisualElement[] _slotBox = new VisualElement[SlotCount];
|
|
readonly VisualElement[] _cdOverlay = new VisualElement[SlotCount];
|
|
readonly Label[] _glyph = new Label[SlotCount];
|
|
readonly Label[] _countdown = new Label[SlotCount];
|
|
readonly Label[] _name = new Label[SlotCount];
|
|
readonly byte[] _shownSpark = new byte[SocketId.Count]; // rebuild labels only when the loadout changes
|
|
readonly int[] _prevRemaining = new int[SlotCount];
|
|
readonly float[] _flashUntil = new float[SlotCount];
|
|
// Track B: last countdown value rendered per slot, in tenths of a second (-1 = blank). Gates the
|
|
// float formatting in UpdateSlotCooldown, which otherwise ran 5x per frame.
|
|
// Seeded to int.MinValue, not the default 0: 0 is a REAL value (a cooldown under ~1/20 s rounds to
|
|
// 0 tenths), and a default-0 array would silently skip that first render.
|
|
readonly int[] _shownDeci = FilledWith(SlotCount, int.MinValue);
|
|
readonly bool[] _shownWhole = new bool[SlotCount]; // which countdown FORMAT is currently rendered per slot
|
|
|
|
static int[] FilledWith(int n, int v) { var a = new int[n]; for (int i = 0; i < n; i++) a[i] = v; return a; }
|
|
|
|
static readonly Color EmptyCol = new(1f, 1f, 1f, 0.22f);
|
|
static readonly Color ReadyGlyphCol = new(0.92f, 0.96f, 1f, 1f);
|
|
static readonly Color CoolingGlyphCol = new(0.92f, 0.96f, 1f, 0.35f);
|
|
static readonly Color OverlayCol = new(0f, 0f, 0f, 0.72f);
|
|
static readonly Color SlotBg = new(0.05f, 0.09f, 0.12f, 0.92f);
|
|
static readonly Color FlashBorder = new(0.55f, 1f, 0.95f, 1f);
|
|
static readonly Color IdleBorder = new(1f, 1f, 1f, 0.10f);
|
|
|
|
protected override void OnStartRunning()
|
|
{
|
|
if (_hudGo != null) return;
|
|
MenuUi.EnsureEventSystem();
|
|
_hudGo = new GameObject("~HUDAbilityBar");
|
|
Object.DontDestroyOnLoad(_hudGo);
|
|
_doc = _hudGo.AddComponent<UIDocument>();
|
|
_doc.panelSettings = MenuUi.LoadPanelSettings();
|
|
_doc.sortingOrder = 49;
|
|
}
|
|
|
|
protected override void OnDestroy()
|
|
{
|
|
if (_hudGo != null) Object.Destroy(_hudGo);
|
|
}
|
|
|
|
protected override void OnUpdate()
|
|
{
|
|
if (_doc == null || _doc.rootVisualElement == null) return;
|
|
if (!_built) { BuildTree(_doc.rootVisualElement); _built = true; }
|
|
|
|
EntityManager.CompleteDependencyBeforeRO<SocketCooldown>();
|
|
EntityManager.CompleteDependencyBeforeRO<EffectiveSocketStats>();
|
|
EntityManager.CompleteDependencyBeforeRO<AbilitySocket>();
|
|
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
|
|
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var nt) || !nt.ServerTick.IsValid) return;
|
|
var now = nt.ServerTick;
|
|
var tcfg = SystemAPI.TryGetSingleton<TuningConfig>(out var tcv) ? tcv : TuningConfig.Defaults();
|
|
|
|
bool haveDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var db) && db.Value.IsCreated;
|
|
|
|
bool foundLocal = false; // no local player (menu / ArtStaging / pre-spawn) -> hide the bar entirely
|
|
foreach (var (cd, dashCd, entity) in
|
|
SystemAPI.Query<RefRO<SocketCooldown>, RefRO<DashCooldown>>()
|
|
.WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess())
|
|
{
|
|
if (!EntityManager.HasBuffer<AbilitySocket>(entity) || !EntityManager.HasBuffer<EffectiveSocketStats>(entity))
|
|
continue;
|
|
foundLocal = true;
|
|
var sockets = EntityManager.GetBuffer<AbilitySocket>(entity, true);
|
|
var effs = EntityManager.GetBuffer<EffectiveSocketStats>(entity, true);
|
|
|
|
int n = math.min(SocketId.Count, math.min(sockets.Length, effs.Length));
|
|
for (int i = 0; i < SocketId.Count; i++)
|
|
{
|
|
byte spark = i < n ? sockets[i].SparkId : (byte)0;
|
|
if (spark != _shownSpark[i])
|
|
{
|
|
_shownSpark[i] = spark;
|
|
RefreshSlotIdentity(i, spark, haveDb, db);
|
|
}
|
|
if (spark == 0 || i >= n) { UpdateSlotCooldown(i, 0, 1); continue; }
|
|
int total = math.max(1, effs[i].CooldownTicks);
|
|
UpdateSlotCooldown(i, RemainingTicks(cd.ValueRO.Get(i), now), total);
|
|
}
|
|
|
|
int dashTotal = math.max(1, (int)tcfg.DashCooldownTicks);
|
|
UpdateSlotCooldown(DashSlot, RemainingTicks(dashCd.ValueRO.NextTick, now), dashTotal);
|
|
break; // one local player
|
|
}
|
|
_doc.rootVisualElement.style.display = foundLocal ? DisplayStyle.Flex : DisplayStyle.None;
|
|
}
|
|
|
|
static int RemainingTicks(uint nextFireRaw, NetworkTick now)
|
|
{
|
|
if (nextFireRaw == 0u) return 0;
|
|
var nextTick = new NetworkTick(nextFireRaw);
|
|
if (!nextTick.IsValid || !nextTick.IsNewerThan(now)) return 0;
|
|
return math.max(0, nextTick.TicksSince(now));
|
|
}
|
|
|
|
void UpdateSlotCooldown(int slot, int remaining, int total)
|
|
{
|
|
float frac = math.saturate(remaining / (float)total);
|
|
_cdOverlay[slot].style.height = Length.Percent(frac * 100f);
|
|
|
|
// Track B: this ran for all 5 slots EVERY frame and formatted a float each time. The readout only
|
|
// has a tenth-of-a-second resolution (6 ticks), so quantise first and only format on a real change.
|
|
//
|
|
// The rendered text is derived from `deci`, NEVER re-derived from `remaining`. Deriving it twice is a
|
|
// trap: Mathf.RoundToInt rounds half-to-EVEN while ToString("0.0") rounds half-AWAY-from-zero, so the
|
|
// gate and the string disagreed at midpoints and the label latched a stale, too-high reading and
|
|
// skipped a tenth on every cooldown. CeilToInt also means the readout never reads LOWER than the true
|
|
// remainder. `whole` is cached alongside deci because the two branches can produce the same deci
|
|
// (596 ticks -> 100 -> "9.9" vs 597 ticks -> 100 -> "10") and the format must still switch.
|
|
bool whole = remaining >= 597;
|
|
int deci = remaining > 0
|
|
? (whole ? Mathf.CeilToInt(remaining / 60f) * 10 : Mathf.CeilToInt(remaining / 6f))
|
|
: -1;
|
|
if (deci != _shownDeci[slot] || whole != _shownWhole[slot])
|
|
{
|
|
_shownDeci[slot] = deci;
|
|
_shownWhole[slot] = whole;
|
|
_countdown[slot].text = deci < 0 ? "" : (whole ? (deci / 10).ToString() : (deci * 0.1f).ToString("0.0"));
|
|
}
|
|
|
|
bool empty = slot < SocketId.Count && _shownSpark[slot] == 0;
|
|
_glyph[slot].style.color = empty ? EmptyCol : (remaining > 0 ? CoolingGlyphCol : ReadyGlyphCol);
|
|
|
|
if (_prevRemaining[slot] > 0 && remaining == 0 && !empty)
|
|
_flashUntil[slot] = UnityEngine.Time.time + k_ReadyFlashSeconds;
|
|
_prevRemaining[slot] = remaining;
|
|
|
|
bool flashing = UnityEngine.Time.time < _flashUntil[slot];
|
|
MenuUi.Border(_slotBox[slot], flashing ? FlashBorder : IdleBorder, flashing ? 2 : 1);
|
|
}
|
|
|
|
void RefreshSlotIdentity(int i, byte spark, bool haveDb, AbilityDatabase db)
|
|
{
|
|
if (spark == 0)
|
|
{
|
|
_glyph[i].text = "—";
|
|
_name[i].text = "empty";
|
|
_glyph[i].style.color = EmptyCol;
|
|
return;
|
|
}
|
|
string full = "Spark " + spark;
|
|
byte arch = 255;
|
|
if (haveDb && db.Value.Value.TryGetAbility(spark, out var def))
|
|
{
|
|
full = def.Name.ToString();
|
|
arch = def.Archetype;
|
|
}
|
|
_name[i].text = full;
|
|
_glyph[i].text = Initials(full);
|
|
_slotBox[i].style.unityBackgroundImageTintColor = ArchTint(arch);
|
|
_slotBox[i].style.backgroundColor = SlotBg;
|
|
}
|
|
|
|
static string Initials(string name)
|
|
{
|
|
var s = "";
|
|
for (int i = 0; i < name.Length && s.Length < 2; i++)
|
|
if (char.IsUpper(name[i])) s += name[i];
|
|
if (s.Length == 0 && name.Length > 0) s = char.ToUpperInvariant(name[0]).ToString();
|
|
return s;
|
|
}
|
|
|
|
static Color ArchTint(byte archetype)
|
|
{
|
|
switch (archetype)
|
|
{
|
|
case (byte)AbilityArchetype.Aoe: return new Color(0.35f, 0.75f, 0.70f, 0.95f); // zones = teal
|
|
case (byte)AbilityArchetype.Movement: return new Color(0.40f, 0.60f, 0.95f, 0.95f); // blink = cool blue
|
|
case (byte)AbilityArchetype.Cone: return new Color(0.95f, 0.70f, 0.30f, 0.95f); // slam = lamp-amber
|
|
case (byte)AbilityArchetype.Hitscan:
|
|
case (byte)AbilityArchetype.Projectile: return new Color(0.85f, 0.80f, 0.60f, 0.95f); // skillshots = warm white
|
|
default: return new Color(0.6f, 0.6f, 0.6f, 0.9f);
|
|
}
|
|
}
|
|
|
|
void BuildTree(VisualElement root)
|
|
{
|
|
root.style.position = Position.Absolute;
|
|
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
|
|
root.pickingMode = PickingMode.Ignore;
|
|
root.style.display = DisplayStyle.None; // hidden until a local player is found (no flash in menu/ArtStaging/pre-connect)
|
|
|
|
var bar = HudUi.Group(Align.Center);
|
|
bar.style.position = Position.Absolute;
|
|
bar.style.bottom = 84; // clear of the build-palette row (24) + discovery chip (28)
|
|
bar.style.left = 0; bar.style.right = 0;
|
|
bar.style.flexDirection = FlexDirection.Row;
|
|
bar.style.justifyContent = Justify.Center;
|
|
root.Add(bar);
|
|
|
|
string[] keys = { "1·RMB", "2", "3", "4", "SHIFT" };
|
|
for (int i = 0; i < SlotCount; i++)
|
|
{
|
|
var col = HudUi.Group(Align.Center);
|
|
col.style.marginLeft = 5; col.style.marginRight = 5;
|
|
|
|
var box = HudUi.Panel(SlotBg);
|
|
box.style.width = 52; box.style.height = 52;
|
|
box.style.justifyContent = Justify.Center;
|
|
box.style.alignItems = Align.Center;
|
|
MenuUi.Border(box, IdleBorder, 1);
|
|
_slotBox[i] = box;
|
|
|
|
var glyph = HudUi.Display(i == DashSlot ? "DA" : "—", 18, ReadyGlyphCol, TextAnchor.MiddleCenter);
|
|
_glyph[i] = glyph;
|
|
box.Add(glyph);
|
|
|
|
var overlay = new VisualElement { pickingMode = PickingMode.Ignore };
|
|
overlay.style.position = Position.Absolute;
|
|
overlay.style.left = 0; overlay.style.right = 0; overlay.style.bottom = 0;
|
|
overlay.style.height = Length.Percent(0);
|
|
overlay.style.backgroundColor = OverlayCol;
|
|
box.Add(overlay);
|
|
_cdOverlay[i] = overlay;
|
|
|
|
var count = HudUi.Display("", 14, new Color(1f, 1f, 1f, 0.95f), TextAnchor.MiddleCenter);
|
|
count.style.position = Position.Absolute;
|
|
count.style.left = 0; count.style.right = 0; count.style.top = 0; count.style.bottom = 0;
|
|
box.Add(count);
|
|
_countdown[i] = count;
|
|
|
|
col.Add(box);
|
|
col.Add(HudUi.Text(keys[i], 10, MenuUi.Accent, TextAnchor.MiddleCenter));
|
|
var name = HudUi.Text(i == DashSlot ? "Dash" : "", 9, MenuUi.SubCol, TextAnchor.MiddleCenter);
|
|
_name[i] = name;
|
|
col.Add(name);
|
|
bar.Add(col);
|
|
}
|
|
|
|
if (_glyph[DashSlot] != null)
|
|
{
|
|
_slotBox[DashSlot].style.unityBackgroundImageTintColor = new Color(0.45f, 0.85f, 1f, 0.95f);
|
|
_slotBox[DashSlot].style.backgroundColor = SlotBg;
|
|
}
|
|
}
|
|
}
|
|
}
|