#include #include #include #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 // 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; 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_fNextExpiry; // Soonest expiry among the names currently on screen. 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. 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, in seconds.", FCVAR_NOTIFY, true, 0.5, true, 30.0); g_cvEnabled.AddChangeHook(OnEnabledChanged); AutoExecConfig(true, "CouchHud"); } public void OnMapStart() { // TIMER_FLAG_NO_MAPCHANGE already killed the timer. g_hExpireTimer = null; g_bFlushQueued = false; g_bHudShown = false; g_fNextExpiry = 0.0; for (int i = 1; i <= MAXPLAYERS; i++) { g_fExpire[i] = 0.0; g_fLastHit[i] = 0.0; } } public void OnClientPutInServer(int client) { g_fExpire[client] = 0.0; g_fLastHit[client] = 0.0; } public void OnClientDisconnect(int client) { bool bListed = (g_fExpire[client] > GetGameTime()); g_fExpire[client] = 0.0; g_fLastHit[client] = 0.0; 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(); } // --------------------------------------------------------------------------------------------- // Couch forwards // --------------------------------------------------------------------------------------------- public void Couch_OnCouchDamaged(int couch, int attacker, float damage) { if (!g_cvEnabled.BoolValue) return; float fNow = GetGameTime(); g_fLastHit[attacker] = fNow; g_fExpire[attacker] = fNow + g_cvTime.FloatValue; QueueFlush(); } public void Couch_OnCouchRemoved() { ResetAll(); } // --------------------------------------------------------------------------------------------- // 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)) == 0) { ClearAll(); return; } Send(); } void Send() { // Hold time matches the listing time, so the text leaves on its own if no update ever comes. SetHudTextParams(0.8, 0.2, g_cvTime.FloatValue, 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); } // Most recent shooters first. Returns how many players are listed, shown or not. Names are // packed until the message is full, then come off the bottom until a "+ N" line fits. int BuildHudText(char[] text, int maxlen) { float fNow = GetGameTime(); bool bTaken[MAXPLAYERS + 1]; int order[MAXPLAYERS + 1]; int total = 0; g_fNextExpiry = 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. 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; if (g_fNextExpiry == 0.0 || g_fExpire[best] < g_fNextExpiry) g_fNextExpiry = g_fExpire[best]; } if (total == 0) { text[0] = '\0'; return 0; } strcopy(text, maxlen, "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 + 2]; FormatEx(sLine, sizeof(sLine), "\n%s", 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 total; } // 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. if (GetGameTime() >= g_fNextExpiry) 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; } ClearAll(); }