Files
Project-M/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs
T
kronic f3aec94c48 HUD: ability bar defaults hidden until a local player exists
Fixes the empty-bar leak into ArtStaging / menu / pre-connect: AbilityBarSystem.OnUpdate
early-returns when there is no valid NetworkTime, so the 'hide when no local player' line never
ran. Root now DisplayStyle.None at build, flips to Flex only when the local player is found —
also kills any pre-connect flash in gameplay.
2026-07-22 18:26:24 -07:00

248 lines
12 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];
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);
_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;
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;
}
}
}
}