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]; // 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(); _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; bool foundLocal = false; // no local player (menu / ArtStaging / pre-spawn) -> hide the bar entirely foreach (var (cd, dashCd, entity) in SystemAPI.Query, RefRO>() .WithAll().WithEntityAccess()) { if (!EntityManager.HasBuffer(entity) || !EntityManager.HasBuffer(entity)) continue; foundLocal = true; 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 } _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; } } } }