diff --git a/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs
new file mode 100644
index 000000000..0302f8541
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs
@@ -0,0 +1,243 @@
+using ProjectM.Simulation;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.NetCode;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace ProjectM.Client
+{
+ ///
+ /// 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 in , 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()).
+ ///
+ [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];
+
+ 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();
+ _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();
+ EntityManager.CompleteDependencyBeforeRO();
+ EntityManager.CompleteDependencyBeforeRO();
+ EntityManager.CompleteDependencyBeforeRO();
+ if (!SystemAPI.TryGetSingleton(out var nt) || !nt.ServerTick.IsValid) return;
+ var now = nt.ServerTick;
+ var tcfg = SystemAPI.TryGetSingleton(out var tcv) ? tcv : TuningConfig.Defaults();
+
+ bool haveDb = SystemAPI.TryGetSingleton(out var db) && db.Value.IsCreated;
+
+ foreach (var (cd, dashCd, entity) in
+ SystemAPI.Query, RefRO>()
+ .WithAll().WithEntityAccess())
+ {
+ if (!EntityManager.HasBuffer(entity) || !EntityManager.HasBuffer(entity))
+ continue;
+ var sockets = EntityManager.GetBuffer(entity, true);
+ var effs = EntityManager.GetBuffer(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
+ }
+ }
+
+ 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);
+ _countdown[slot].text = remaining > 0
+ ? (remaining < 597 ? (remaining / 60f).ToString("0.0") : Mathf.CeilToInt(remaining / 60f).ToString())
+ : "";
+ 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;
+
+ 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;
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs.meta
new file mode 100644
index 000000000..7908e0472
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d61b6256713fa5949b17cd75b7b66357
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
index 6fd8c0ac1..d6e8cb959 100644
--- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
@@ -49,7 +49,7 @@ namespace ProjectM.Client
bool _themed; // HudTheme + PanelBox present (drives sprite-tint vs flat-colour retint)
// vitals
- VisualElement _healthFill, _cooldownFill, _shieldRow, _cdRow;
+ VisualElement _healthFill, _shieldRow;
Label _healthText;
// threat
@@ -327,7 +327,7 @@ namespace ProjectM.Client
// ---- Per-player vitals ----
bool found = false;
- float hp = 0f, maxHp = 1f, cdFrac = 1f;
+ float hp = 0f, maxHp = 1f;
bool dead = false, shielded = false;
foreach (var (health, effChar, cd, invuln, entity) in
@@ -340,19 +340,7 @@ namespace ProjectM.Client
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
dead = SystemAPI.IsComponentEnabled(entity);
- // Cooldown bar = socket 0 (the primary Spark) of the 4-socket kit (the legacy single
- // AbilityCooldown died — LANTERN purge). EffectiveSocketStats row 0 supplies the duration.
- uint nextFire = cd.ValueRO.Get(0);
- int cdTicks = 0;
- if (SystemAPI.HasBuffer(entity))
- {
- var effSockets = SystemAPI.GetBuffer(entity);
- if (effSockets.Length > 0) cdTicks = effSockets[0].CooldownTicks;
- }
- var nextTick = new NetworkTick(nextFire);
- cdFrac = (haveTick && nextFire != 0 && cdTicks > 0 && nextTick.IsValid && nextTick.IsNewerThan(nt.ServerTick))
- ? Mathf.Clamp01(1f - nextTick.TicksSince(nt.ServerTick) / (float)cdTicks)
- : 1f;
+ // (07-21 UI rework: socket/dash cooldown readouts moved to AbilityBarSystem.)
uint invulnUntil = invuln.ValueRO.UntilTick;
var invulnTick = new NetworkTick(invulnUntil);
@@ -381,9 +369,6 @@ namespace ProjectM.Client
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp);
_shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
- HudUi.SetFill(_cooldownFill, cdFrac);
- // A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads.
- if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f;
if (dead)
{
// Client-local countdown: latch the death edge; the baked (non-replicated) DelayTicks is the
@@ -649,19 +634,8 @@ namespace ProjectM.Client
_shieldRow.style.display = DisplayStyle.None;
panel.Add(_shieldRow);
- // cooldown row: weapon icon + thin bar
- _cdRow = new VisualElement();
- _cdRow.style.flexDirection = FlexDirection.Row;
- _cdRow.style.alignItems = Align.Center;
- _cdRow.style.marginBottom = 6;
- _cdRow.pickingMode = PickingMode.Ignore;
- var cdIcon = HudUi.Icon(theme != null ? theme.CooldownIcon : null, 22, AetherCyan);
- cdIcon.style.marginRight = 8;
- _cdRow.Add(cdIcon);
- var cdBar = HudUi.Bar(420, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill);
- _cdRow.Add(cdBar);
- panel.Add(_cdRow);
-
+ // 07-21 UI rework: the single socket-0 charge strip is gone — AbilityBarSystem (bottom-center)
+ // now shows ALL socket cooldowns + dash.
// health row: health icon + big bar with numeric overlay
var hpRow = new VisualElement();
hpRow.style.flexDirection = FlexDirection.Row;