initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
268
Outnumbered/Hud.cs
Normal file
268
Outnumbered/Hud.cs
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
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(" ");
|
||||
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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue