From 040463ad07a214cda438110da582fe4a2e91a3ec Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Tue, 21 Jul 2026 13:28:08 -0700 Subject: [PATCH] HUD: ability/cooldown bar (sockets 1-4 + dash); socket-0 charge strip removed AbilityBarSystem (own UIDocument, sortingOrder 49, the B5 sibling pattern): per-slot Spark initials/name from the AbilityDatabase blob, archetype tint, drain overlay + seconds countdown + ready flash; dash rides DashCooldown vs TuningConfig.DashCooldownTicks (Defaults() fallback). HudSystem's single-socket blue charge strip removed (it tracked socket 0 only). Co-Authored-By: Claude Fable 5 --- .../Client/Presentation/AbilityBarSystem.cs | 243 ++++++++++++++++++ .../Presentation/AbilityBarSystem.cs.meta | 2 + .../Scripts/Client/Presentation/HudSystem.cs | 36 +-- 3 files changed, 250 insertions(+), 31 deletions(-) create mode 100644 Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs create mode 100644 Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs.meta 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;