using System.Text; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using Outnumbered.Data; using Outnumbered.Domain; using Outnumbered.Engine; namespace Outnumbered; // The always-on HUD: handicap multipliers (Dmg Out/In, XP), level/prestige/streak, and the 5 ability states. // // Mode "world": a per-player CPointWorldText entity placed in front of the camera (positionable, font-sized). // It's a real world entity, so CheckTransmit must hide each player's HUD from everyone else. Single-color text. // Mode "center": a PrintToCenterHtml panel (multi-color, but fixed center + fixed width). public sealed partial class OutnumberedPlugin { private readonly HashSet _hudOff = []; // per-player !hud opt-out private readonly Dictionary _hudEntities = []; // slot -> world-text entity (world mode) private readonly Dictionary _hudText = []; // slot -> last text pushed (skip redundant SetMessage) private readonly HashSet _centerShown = []; // center mode: slots currently showing the panel private int _hudTick; private readonly StringBuilder _hudSb = new(); // reused across HUD builds (game-thread, sequential) private readonly List _hudReap = []; // reused stale-slot scratch (world mode), avoids a per-tick Keys.ToList() private Action? _hudReapAction; // cached DestroyHud delegate (so ReapOrphanSlots adds no per-tick closure) private static readonly (int v, string s)[] RomanMap = [ (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I") ]; // Prestige rendered as a Roman numeral (0 = no prestige). public static string Roman(int n) { if (n <= 0) return "0"; var sb = new StringBuilder(); foreach (var (v, s) in RomanMap) while (n >= v) { sb.Append(s); n -= v; } return sb.ToString(); } private void Initialize_Hud() { // OnTick is driven by OnTick_All (shared roster walk); OnTick_Hud is invoked from there. RegisterListener(OnCheckTransmit_Hud); } private void Shutdown_Hud() { RemoveListener(OnCheckTransmit_Hud); foreach (var slot in _hudEntities.Keys.ToList()) DestroyHud(slot); } // ---- per-tick ---- private void OnTick_Hud(List players) { if (!Config.Hud.Enabled) { return; } int every = Math.Max(1, Config.Hud.RefreshEveryTicks); if (++_hudTick % every != 0) return; bool world = !string.Equals(Config.Hud.Mode, "center", StringComparison.OrdinalIgnoreCase); // Reap entities for players who disconnected (world mode) — shared collect-then-act helper (DestroyHud mutates // _hudEntities), reused scratch + cached delegate so there's no per-tick alloc. if (world && _hudEntities.Count > 0) ReapOrphanSlots(_hudEntities, _hudReap, _hudReapAction ??= DestroyHud); foreach (var p in players) { if (!IsHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path) var sid = p.AuthorizedSteamID?.SteamId64; // drives the per-player !hud opt-out (_hudOff) bool show = sid is not null && !_hudOff.Contains(sid.Value) && p.PawnIsAlive && _players.TryGetValue(sid.Value, out _); if (!world) { if (show && _players.TryGetValue(sid!.Value, out var cpd)) { p.PrintToCenterHtml(BuildHudHtml(p, cpd)); _centerShown.Add(p.Slot); } else if (_centerShown.Remove(p.Slot)) // was showing, now hidden -> clear the stale panel once (it doesn't self-clear) { p.PrintToCenterHtml(""); } continue; } if (!show) { DestroyHud(p.Slot); continue; } _players.TryGetValue(sid!.Value, out var pd); var ent = EnsureHud(p); if (ent is null || pd is null) continue; UpdateWorldHud(p, ent, pd); } } // each player sees only their own HUD entity private void OnCheckTransmit_Hud(CCheckTransmitInfoList infoList) => HideForeignSlotEntities(infoList, _hudEntities, static (info, ent) => { if (ent is { IsValid: true }) info.TransmitEntities.Remove(ent); }); // ---- world-text entity ---- private CPointWorldText? EnsureHud(CCSPlayerController p) { if (_hudEntities.TryGetValue(p.Slot, out var ent) && ent is { IsValid: true }) return ent; _hudEntities.Remove(p.Slot); _hudText.Remove(p.Slot); var created = CreateHud(); if (created is not null) _hudEntities[p.Slot] = created; return created; } private CPointWorldText? CreateHud() { var h = Config.Hud; return WorldText.Create(h.FontSize, h.WorldUnitsPerPx, h.FontName, System.Drawing.Color.FromArgb(255, h.ColorR, h.ColorG, h.ColorB), h.DrawBackground, 0.1f, PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER); } private void DestroyHud(int slot) { if (_hudEntities.Remove(slot, out CPointWorldText? ent)) WorldText.Destroy(ref ent); _hudText.Remove(slot); } private void UpdateWorldHud(CCSPlayerController p, CPointWorldText ent, PlayerData pd) { var pawn = p.PlayerPawn.Value; if (pawn is null || !WorldText.TryEyeFrame(pawn, out var eye, out var fwd, out var right, out var up, out var ang)) return; var h = Config.Hud; var pos = eye + fwd * h.ForwardOffset + right * h.RightOffset + up * h.UpOffset; string text = BuildHudText(p, pd); if (!_hudText.TryGetValue(p.Slot, out var prev) || prev != text) { WorldText.SetText(ent, text); _hudText[p.Slot] = text; } WorldText.Place(ent, pos, ang); } // ---- effective multipliers shown on the HUD / shop / !stats ---- // The SINGLE source for the four readouts: build ONE snapshot + run ONE ComputeT (via HandicapModel.Bands) and derive // HS / Out / In / XP from it. Out/In come from the SAME shared CombatResolver chains the damage hook applies // (headshot:false, crit:false = the base readout), so a readout can never drift from real damage. Used by the HUD // (per tick) and the cold shop/!stats paths. private (double hs, double md, double mt, double xp) EffectiveMultipliers(CCSPlayerController p, PlayerData pd) { var s = Snapshot(pd, p); HandicapModel.Bands(s, _hcap, out double deal, out double take, out double xpBand); double md = CombatResolver.OffenseMultiplier(s, headshot: false, crit: false, _statDefs, Config.Abilities, BaseMaxHp, deal); double mt = CombatResolver.DefenseMultiplier(s, headshot: false, Config.Abilities, take); double hs = 1.0 + StatResolver.EffRun(s, StatKeys.HeadshotDamage, _statDefs) / 100.0; double xp = (1.0 + StatResolver.Eff(s, StatKeys.XpBoost, _statDefs) / 100.0) * ProgressionModel.PrestigeXpMultiplier(pd.Prestige, Config.Progression) * xpBand; return (hs, md, mt, xp); } // ---- content ---- private string BuildHudText(CCSPlayerController p, PlayerData pd) { var (hs, md, mt, xp) = EffectiveMultipliers(p, pd); long next = XpToNext(pd.Level); double now = Server.CurrentTime; // read once for all ability segments (clock can't advance within this build) var sb = _hudSb; sb.Clear(); if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append(wline).Append('\n'); sb.Append($"Lvl {pd.Level} ({pd.Xp}/{next}) Prestige {Roman(pd.Prestige)} {pd.Points} pt Streak {pd.Streak}\n"); sb.Append($"HS x{hs:F2} Out x{md:F2} In x{mt:F2} XP x{xp:F2}\n"); for (int i = 0; i < AbilityCount; i++) { if (i > 0) sb.Append(" "); sb.Append(AbilityTextSegment(pd, i, now)); } return sb.ToString(); } private string AbilityTextSegment(PlayerData pd, int i, double now) { string suffix = AbilityActive(pd, i, now) ? $" ON{pd.AbilityActiveUntil[i] - now:F0}" : !AbilityReady(pd, i, now) ? $" {pd.AbilityReadyAt[i] - now:F0}s" : AbilityUnlocked(pd, i) ? "" : $" L{AbilityCfg(i).StreakReq}"; return $"{AbilityRegistry[i].KeyNum}:{AbilityRegistry[i].Short}{suffix}"; } // ---- center-HTML mode (crisp, multi-color, default) ---- private string AbilityIcon(int i) { var list = Config.Hud.AbilityIcons; return i >= 0 && i < list.Count && !string.IsNullOrEmpty(list[i]) ? list[i] : AbilityRegistry[i].Short; } private string BuildHudHtml(CCSPlayerController p, PlayerData pd) { var (hs, md, mt, xp) = EffectiveMultipliers(p, pd); long next = XpToNext(pd.Level); double now = Server.CurrentTime; // read once for the ability segments + the prestige animation phase string streakColor = pd.Streak > 0 ? "#ffd000" : "#bbbbbb"; string ptsColor = pd.Points > 0 ? "#66ff66" : "#888888"; var sb = _hudSb; sb.Clear(); if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append($"{wline}
"); sb.Append($"Lv{pd.Level} {PrestigeHudTag(pd.Prestige, now)}"); sb.Append($" {pd.Points} pt"); sb.Append($" Streak {pd.Streak}"); int xpPct = (int)(pd.Xp * 100 / Math.Max(1, next)); // progress through current level; 0-100 sb.Append($" {xpPct}% xp
"); sb.Append($"HS {hs:F2}"); sb.Append($" Out {md:F2}"); sb.Append($" In {mt:F2}"); sb.Append($" XP {xp:F2}
"); for (int i = 0; i < AbilityCount; i++) { if (i > 0) sb.Append("   "); sb.Append(AbilityHudSegment(pd, i, now)); } return sb.ToString(); } // Compact icon, colored by state: green ready, cyan active(+secs), orange cooldown(+secs), gray locked(+req). private string AbilityHudSegment(PlayerData pd, int i, double now) { string color, suffix; if (AbilityActive(pd, i, now)) { color = "#55ddff"; suffix = $"{pd.AbilityActiveUntil[i] - now:F0}"; } else if (!AbilityReady(pd, i, now)) { color = "#ff8800"; suffix = $"{pd.AbilityReadyAt[i] - now:F0}"; } else if (AbilityUnlocked(pd, i)) { color = "#66ff66"; suffix = ""; } else { color = "#777777"; suffix = $"{AbilityCfg(i).StreakReq}"; } return $"{AbilityIcon(i)}{suffix}"; } // Prestige tag for the HUD: solid (I-V = perk colour) or an animated flowing gradient (VI-X). private string PrestigeHudTag(int prestige, double now) { string text = $"P{Roman(prestige)}"; if (!_ranks.PrestigeColors || prestige <= 0) return $"{text}"; var cols = PrestigeColorSet(prestige); if (cols.Length == 1) { var (r, g, b) = cols[0]; return $"{text}"; } double phase = now * 0.30; // flowing animation for VI-X int letters = 0; foreach (char ch in text) if (ch != ' ') letters++; var sb = new StringBuilder(); int li = 0; foreach (char ch in text) { if (ch == ' ') { sb.Append(' '); continue; } double t = (letters <= 1 ? 0.0 : (double)li / letters) + phase; var (r, g, b) = SampleRing(cols, t); sb.Append($"{ch}"); li++; } return sb.ToString(); } // Sample a looping colour ring at position t (wraps), linear-interpolated between adjacent stops. private static (int r, int g, int b) SampleRing((int r, int g, int b)[] c, double t) { int n = c.Length; t = (t % 1.0 + 1.0) % 1.0; double scaled = t * n; double fl = Math.Floor(scaled); int i0 = (int)fl % n, i1 = (i0 + 1) % n; double f = scaled - fl; var a = c[i0]; var b2 = c[i1]; return ((int)(a.r + (b2.r - a.r) * f), (int)(a.g + (b2.g - a.g) * f), (int)(a.b + (b2.b - a.b) * f)); } }