#include #include #include // Optional: without Zombie:Reloaded nobody is tagged as a zombie. #undef REQUIRE_PLUGIN #include #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 " " // Put in front of the name of anyone who was a zombie when they last hit or pushed the couch. #define HUD_ZOMBIE_TAG " " // 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(); }