cs2-outnumbered/Outnumbered/Hud.cs
Kamal Tufekcic d701598350
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s
initial commit
2026-07-05 13:28:35 +03:00

268 lines
13 KiB
C#

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<ulong> _hudOff = []; // per-player !hud opt-out
private readonly Dictionary<int, CPointWorldText> _hudEntities = []; // slot -> world-text entity (world mode)
private readonly Dictionary<int, string> _hudText = []; // slot -> last text pushed (skip redundant SetMessage)
private readonly HashSet<int> _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<int> _hudReap = []; // reused stale-slot scratch (world mode), avoids a per-tick Keys.ToList()
private Action<int>? _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<Listeners.CheckTransmit>(OnCheckTransmit_Hud);
}
private void Shutdown_Hud()
{
RemoveListener<Listeners.CheckTransmit>(OnCheckTransmit_Hud);
foreach (var slot in _hudEntities.Keys.ToList()) DestroyHud(slot);
}
// ---- per-tick ----
private void OnTick_Hud(List<CCSPlayerController> 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($"<font color='#ff5a5a'>{wline}</font><br>");
sb.Append($"<font color='#ffae00'>Lv{pd.Level}</font> {PrestigeHudTag(pd.Prestige, now)}");
sb.Append($" <font color='{ptsColor}'>{pd.Points} pt</font>");
sb.Append($" <font color='{streakColor}'>Streak {pd.Streak}</font>");
int xpPct = (int)(pd.Xp * 100 / Math.Max(1, next)); // progress through current level; 0-100
sb.Append($" <font color='#cccccc'>{xpPct}% xp</font><br>");
sb.Append($"<font color='#ffffff'>HS </font><font color='#ffcf6b'>{hs:F2}</font>");
sb.Append($" <font color='#ffffff'>Out </font><font color='#ff9a9a'>{md:F2}</font>");
sb.Append($" <font color='#ffffff'>In </font><font color='#ff9a9a'>{mt:F2}</font>");
sb.Append($" <font color='#ffffff'>XP </font><font color='#7dff7d'>{xp:F2}</font><br>");
for (int i = 0; i < AbilityCount; i++)
{
if (i > 0) sb.Append(" &nbsp; ");
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 $"<font color='{color}'>{AbilityIcon(i)}{suffix}</font>";
}
// 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 $"<font color='#cc88ff'>{text}</font>";
var cols = PrestigeColorSet(prestige);
if (cols.Length == 1)
{
var (r, g, b) = cols[0];
return $"<font color='#{r:x2}{g:x2}{b:x2}'>{text}</font>";
}
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($"<font color='#{r:x2}{g:x2}{b:x2}'>{ch}</font>");
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));
}
}