Files
sm-plugins/Couch/scripting/CouchHud.sp
T
2026-10-14 16:53:45 +01:00

517 lines
14 KiB
SourcePawn

#include <sourcemod>
#include <sdktools>
#include <Couch>
// Optional: without Zombie:Reloaded nobody is tagged as a zombie.
#undef REQUIRE_PLUGIN
#include <zombiereloaded>
#define REQUIRE_PLUGIN
#pragma semicolon 1
#pragma newdecls required
// How often expired names are swept off the list. Only resends if a name actually dropped.
#define HUD_EXPIRE_INTERVAL 0.25
// Longer names are cut so one can't eat the whole message.
#define HUD_NAME_BYTES 20
// +use reports every tick it's held; the list is resent at most this often for it. Well under
// sm_couch_hud_time's minimum, so the text never lapses while someone keeps pushing.
#define HUD_USE_RESEND 0.1
// Put in front of the name of anyone whose grenade hit the couch recently.
#define HUD_GRENADE_TAG "<G!> "
// Put in front of the name of anyone who was a zombie when they last hit or pushed the couch.
#define HUD_ZOMBIE_TAG "<Z> "
// First line while the couch is below full HP: "♥ 500/1000". It stays up on its own, so it is resent
// this often; that also catches players who joined after the last send. Held for two refreshes so a
// late timer tick can't let it lapse.
#define HUD_HP_HEART "♥"
#define HUD_HP_REFRESH 5.0
#define HUD_HP_HOLD (HUD_HP_REFRESH * 2.0)
// While the couch is below full and not healing, the heart blinks between "♥" and "♥!" on every
// tick of the expiry timer, so each state shows for HUD_EXPIRE_INTERVAL. Healing means a regen
// tick raised the HP within this long; regen ticks once a second, so a healing couch never lapses
// between ticks. A drop ends the healing state at once, so the blink starts on the hit. (The HUD
// fonts only cover the WGL4 set, which has ♥ but no empty heart, hence the "!" suffix.)
#define HUD_HP_HEART_ALERT "!"
#define HUD_HP_HEAL_HOLD 1.5
// A user message holds 255 bytes; HudMsg's fixed fields take 34 and the string its terminator.
// Overflowing drops the whole message.
#define HUD_MSG_BYTES 255
#define HUD_MSG_HEADER_BYTES 34
#define HUD_TEXT_MAX (HUD_MSG_BYTES - HUD_MSG_HEADER_BYTES - 1)
ConVar g_cvEnabled;
ConVar g_cvTime;
ConVar g_cvGrenadeTime;
Handle g_hHudSync;
Handle g_hExpireTimer;
float g_fExpire[MAXPLAYERS + 1]; // GetGameTime() at which this client drops off the list.
float g_fLastHit[MAXPLAYERS + 1]; // Ordering key: the most recent shooter is listed first.
float g_fGrenadeUntil[MAXPLAYERS + 1]; // GetGameTime() at which this client loses the grenade tag.
float g_fNextUseSend[MAXPLAYERS + 1]; // GetGameTime() before which this client's +use doesn't resend.
bool g_bZombieHit[MAXPLAYERS + 1]; // This client was a zombie at their last hit or push.
bool g_bZRLoaded;
float g_fNextExpiry; // Soonest expiry or tag drop among the names currently listed.
float g_fLastExpiry; // Latest expiry among them; the text is held until then.
bool g_bFlushQueued; // A send is already scheduled for the next frame.
bool g_bHudShown; // Clients currently have text up, so an empty list must clear it.
bool g_bHpLine; // The text starts with the HP line, so it must stay up even with nobody listed.
float g_fNextHpRefresh; // GetGameTime() at which the HP line is resent.
int g_iLastHp; // HP as of the last Couch_OnCouchHpChanged, or -1 if none seen.
float g_fHealUntil; // GetGameTime() until which the couch counts as healing; the heart is solid until then.
bool g_bHeartAlert; // The "♥!" state is the one currently up.
char g_sHudText[HUD_TEXT_MAX + 1];
public Plugin myinfo =
{
name = "Couch HUD",
author = "marisakirisame.net",
description = "Shows who is shooting the couch",
version = "2.0",
url = "https://marisakirisame.net"
};
public void OnPluginStart()
{
g_hHudSync = CreateHudSynchronizer();
g_cvEnabled = CreateConVar("sm_couch_hud_enabled", "1", "Show the list of players shooting the couch.", FCVAR_NOTIFY, true, 0.0, true, 1.0);
g_cvTime = CreateConVar("sm_couch_hud_time", "0.5", "How long a player stays on the list after their last hit or +use push, in seconds.", FCVAR_NOTIFY, true, 0.5, true, 30.0);
g_cvGrenadeTime = CreateConVar("sm_couch_hud_time_grenade", "4.0", "How long a player stays on the list, tagged " ... HUD_GRENADE_TAG ... ", after their grenade hits the couch, in seconds.", FCVAR_NOTIFY, true, 0.5, true, 30.0);
g_cvEnabled.AddChangeHook(OnEnabledChanged);
AutoExecConfig(true, "CouchHud");
}
public void OnAllPluginsLoaded()
{
g_bZRLoaded = LibraryExists("zombiereloaded");
}
public void OnLibraryAdded(const char[] name)
{
if (StrEqual(name, "zombiereloaded"))
g_bZRLoaded = true;
}
public void OnMapStart()
{
// TIMER_FLAG_NO_MAPCHANGE already killed the timer.
g_hExpireTimer = null;
g_bFlushQueued = false;
g_bHudShown = false;
g_fNextExpiry = 0.0;
g_fLastExpiry = 0.0;
g_bHpLine = false;
g_fNextHpRefresh = 0.0;
g_iLastHp = -1;
g_fHealUntil = 0.0;
g_bHeartAlert = false;
for (int i = 1; i <= MAXPLAYERS; i++)
{
g_fExpire[i] = 0.0;
g_fLastHit[i] = 0.0;
g_fGrenadeUntil[i] = 0.0;
g_fNextUseSend[i] = 0.0;
g_bZombieHit[i] = false;
}
}
public void OnClientPutInServer(int client)
{
g_fExpire[client] = 0.0;
g_fLastHit[client] = 0.0;
g_fGrenadeUntil[client] = 0.0;
g_fNextUseSend[client] = 0.0;
g_bZombieHit[client] = false;
}
public void OnClientDisconnect(int client)
{
bool bListed = (g_fExpire[client] > GetGameTime());
g_fExpire[client] = 0.0;
g_fLastHit[client] = 0.0;
g_fGrenadeUntil[client] = 0.0;
g_fNextUseSend[client] = 0.0;
g_bZombieHit[client] = false;
if (bListed)
QueueFlush();
}
public void OnEnabledChanged(ConVar convar, const char[] oldValue, const char[] newValue)
{
if (!convar.BoolValue)
ResetAll();
}
public void OnLibraryRemoved(const char[] name)
{
if (StrEqual(name, "Couch"))
ResetAll();
else if (StrEqual(name, "zombiereloaded"))
g_bZRLoaded = false;
}
// ---------------------------------------------------------------------------------------------
// Couch forwards
// ---------------------------------------------------------------------------------------------
public void Couch_OnCouchDamaged(int couch, int attacker, float damage)
{
if (!g_cvEnabled.BoolValue)
return;
// Extended, never shortened, so gunfire right after a grenade doesn't cut its time short.
float fNow = GetGameTime();
g_fLastHit[attacker] = fNow;
g_fExpire[attacker] = MaxFloat(g_fExpire[attacker], fNow + g_cvTime.FloatValue);
g_bZombieHit[attacker] = (g_bZRLoaded && ZR_IsClientZombie(attacker));
QueueFlush();
}
// Hits fire just after Couch_OnCouchDamaged for the same hit, so the ordering is already updated.
// +use never goes through damage, so it is listed from here.
public void Couch_OnCouchInteraction(int couch, int client, CouchMethod method, const char[] weapon)
{
if (!g_cvEnabled.BoolValue)
return;
float fNow = GetGameTime();
// Every method lands here, so this covers +use and grenades, which never go through damage.
g_bZombieHit[client] = (g_bZRLoaded && ZR_IsClientZombie(client));
if (method == CouchMethod_Use)
{
g_fLastHit[client] = fNow;
g_fExpire[client] = MaxFloat(g_fExpire[client], fNow + g_cvTime.FloatValue);
if (fNow < g_fNextUseSend[client])
return;
g_fNextUseSend[client] = fNow + HUD_USE_RESEND;
QueueFlush();
return;
}
if (method != CouchMethod_Grenade)
return;
g_fGrenadeUntil[client] = fNow + g_cvGrenadeTime.FloatValue;
g_fExpire[client] = MaxFloat(g_fExpire[client], g_fGrenadeUntil[client]);
QueueFlush();
}
float MaxFloat(float a, float b)
{
return (a > b) ? a : b;
}
public void Couch_OnCouchRemoved()
{
ResetAll();
}
// Regen ticks land here once a second; the flush decides whether the line is still wanted. A rise
// means a regen tick (or an admin raising it) and keeps the heart solid; a drop means a zombie hit
// (or an admin lowering it) and starts the blink at once.
public void Couch_OnCouchHpChanged(int couch, int hp, int maxhp)
{
if (!g_cvEnabled.BoolValue)
return;
if (g_iLastHp >= 0)
{
if (hp > g_iLastHp)
g_fHealUntil = GetGameTime() + HUD_HP_HEAL_HOLD;
else if (hp < g_iLastHp)
g_fHealUntil = 0.0;
}
g_iLastHp = hp;
QueueFlush();
}
// ---------------------------------------------------------------------------------------------
// Sending
// ---------------------------------------------------------------------------------------------
// Once per frame: every hit in a frame (all shotgun pellets, several shooters) lands here, but
// only the first schedules a send.
void QueueFlush()
{
if (g_bFlushQueued)
return;
g_bFlushQueued = true;
RequestFrame(Frame_Flush);
}
void Frame_Flush(any data)
{
g_bFlushQueued = false;
Flush();
}
void Flush()
{
if (!g_cvEnabled.BoolValue || Couch_GetCouch() == -1)
{
ResetAll();
return;
}
if (!BuildHudText(g_sHudText, sizeof(g_sHudText)))
{
ClearAll();
return;
}
Send();
}
void Send()
{
// Held until the last name lapses, so the text leaves on its own if no update ever comes.
// Names lapsing sooner are dropped by a resend from the expiry timer. The HP line outlives
// the names and is kept up by the refresh instead.
float fNow = GetGameTime();
float fHold = g_fLastExpiry - fNow;
if (g_bHpLine)
{
if (fHold < HUD_HP_HOLD)
fHold = HUD_HP_HOLD;
g_fNextHpRefresh = fNow + HUD_HP_REFRESH;
}
SetHudTextParams(0.8, 0.2, fHold, 255, 255, 255, 255, 0, 0.0, 0.0, 0.0);
for (int i = 1; i <= MaxClients; i++)
{
if (IsClientInGame(i) && !IsFakeClient(i))
ShowSyncHudText(i, g_hHudSync, "%s", g_sHudText);
}
g_bHudShown = true;
// Started here rather than from the hit, so a tick between hit and send can't stop the timer.
if (g_hExpireTimer == null)
g_hExpireTimer = CreateTimer(HUD_EXPIRE_INTERVAL, Timer_Expire, _, TIMER_REPEAT | TIMER_FLAG_NO_MAPCHANGE);
}
// HP line first when the couch is below full, then the most recent shooters. Returns whether there
// is anything to show. Names are packed until the message is full, then come off the bottom until
// a "+ N" line fits.
bool BuildHudText(char[] text, int maxlen)
{
float fNow = GetGameTime();
bool bTaken[MAXPLAYERS + 1];
int order[MAXPLAYERS + 1];
int total = 0;
g_fNextExpiry = 0.0;
g_fLastExpiry = 0.0;
// Selection sort, most recent hit first. The soonest expiry is tracked over everyone listed,
// since a hidden player lapsing still changes the "+ N" count. A grenade tag dropping off a
// name that stays listed changes the text too, so it counts as an expiry.
for (;;)
{
int best = 0;
for (int i = 1; i <= MaxClients; i++)
{
if (bTaken[i] || g_fExpire[i] <= fNow || !IsClientInGame(i))
continue;
if (best == 0 || g_fLastHit[i] > g_fLastHit[best])
best = i;
}
if (best == 0)
break;
bTaken[best] = true;
order[total++] = best;
float fChange = g_fExpire[best];
if (g_fGrenadeUntil[best] > fNow && g_fGrenadeUntil[best] < fChange)
fChange = g_fGrenadeUntil[best];
if (g_fNextExpiry == 0.0 || fChange < g_fNextExpiry)
g_fNextExpiry = fChange;
if (g_fExpire[best] > g_fLastExpiry)
g_fLastExpiry = g_fExpire[best];
}
g_bHpLine = (Couch_IsHpEnabled() && Couch_GetHp() < Couch_GetMaxHp());
text[0] = '\0';
if (g_bHpLine)
{
// Solid while healing; the timer only flips it otherwise.
if (fNow < g_fHealUntil)
g_bHeartAlert = false;
char sHp[16], sMax[16];
FormatThousands(Couch_GetHp(), sHp, sizeof(sHp));
FormatThousands(Couch_GetMaxHp(), sMax, sizeof(sMax));
FormatEx(text, maxlen, HUD_HP_HEART ... "%s %s/%s", g_bHeartAlert ? HUD_HP_HEART_ALERT : "", sHp, sMax);
}
if (total == 0)
return g_bHpLine;
StrCat(text, maxlen, g_bHpLine ? "\nShooting couch:" : "Shooting couch:");
// rowStart[n] is the text length before row n, so a row can be cut back off.
int rowStart[MAXPLAYERS + 1];
int rows = 0;
for (; rows < total; rows++)
{
char sName[MAX_NAME_LENGTH];
GetClientName(order[rows], sName, sizeof(sName));
TruncateUtf8(sName, HUD_NAME_BYTES);
char sLine[HUD_NAME_BYTES + sizeof(HUD_ZOMBIE_TAG) + sizeof(HUD_GRENADE_TAG) + 1];
FormatEx(sLine, sizeof(sLine), "\n%s%s%s", g_bZombieHit[order[rows]] ? HUD_ZOMBIE_TAG : "", (g_fGrenadeUntil[order[rows]] > fNow) ? HUD_GRENADE_TAG : "", sName);
int len = strlen(text);
if (len + strlen(sLine) >= maxlen)
break;
rowStart[rows] = len;
StrCat(text, maxlen, sLine);
}
// Overflow line. A longer count can need another byte, so the fit is rechecked after every drop.
while (rows < total)
{
char sLine[16];
FormatEx(sLine, sizeof(sLine), "\n+ %d", total - rows);
if (strlen(text) + strlen(sLine) < maxlen)
{
StrCat(text, maxlen, sLine);
break;
}
rows--;
text[rowStart[rows]] = '\0';
}
return true;
}
// Cuts to at most maxBytes without leaving a partial multi-byte character on the end.
void TruncateUtf8(char[] text, int maxBytes)
{
if (strlen(text) <= maxBytes)
return;
int cut = maxBytes;
// Back up over continuation bytes so the whole character is dropped.
while (cut > 0 && (text[cut] & 0xC0) == 0x80)
cut--;
text[cut] = '\0';
}
public Action Timer_Expire(Handle timer)
{
if (!g_bHudShown)
{
g_hExpireTimer = null;
return Plugin_Stop;
}
// Only resend when a name has lapsed, the HP line is due its refresh, or the heart has to flip.
// With nobody listed there is no expiry (0.0), so the guard stops the HP line being resent
// every tick.
float fNow = GetGameTime();
bool bExpiry = (g_fNextExpiry > 0.0 && fNow >= g_fNextExpiry);
bool bRefresh = (g_bHpLine && fNow >= g_fNextHpRefresh);
bool bHeart = false;
if (g_bHpLine)
{
if (fNow >= g_fHealUntil)
{
// Not healing: alternate every tick.
g_bHeartAlert = !g_bHeartAlert;
bHeart = true;
}
else if (g_bHeartAlert)
{
// Healing started while "♥!" was up; put the solid heart back.
bHeart = true;
}
}
if (bExpiry || bRefresh || bHeart)
Flush();
return Plugin_Continue;
}
void ClearAll()
{
if (!g_bHudShown)
return;
for (int i = 1; i <= MaxClients; i++)
{
if (IsClientInGame(i) && !IsFakeClient(i))
ClearSyncHud(i, g_hHudSync);
}
g_bHudShown = false;
}
// The expiry timer sees g_bHudShown clear on its next tick and stops itself, so this is safe
// to call from inside it.
void ResetAll()
{
for (int i = 1; i <= MAXPLAYERS; i++)
{
g_fExpire[i] = 0.0;
g_fLastHit[i] = 0.0;
g_fGrenadeUntil[i] = 0.0;
g_fNextUseSend[i] = 0.0;
g_bZombieHit[i] = false;
}
g_bHpLine = false;
g_fNextHpRefresh = 0.0;
g_iLastHp = -1;
g_fHealUntil = 0.0;
g_bHeartAlert = false;
ClearAll();
}