From 867b3bfbba9af709e870f0d0c8d04177564ebfd5 Mon Sep 17 00:00:00 2001 From: Marisa Kirisame Date: Tue, 29 Sep 2026 02:19:04 +0100 Subject: [PATCH] Add Couch plugins --- Couch/gamedata/Couch.txt | 34 ++ Couch/scripting/Couch.sp | 892 ++++++++++++++++++++++++++++++ Couch/scripting/CouchAdmin.sp | 250 +++++++++ Couch/scripting/CouchBan.sp | 246 ++++++++ Couch/scripting/CouchHud.sp | 316 +++++++++++ Couch/scripting/include/Couch.inc | 144 +++++ 6 files changed, 1882 insertions(+) create mode 100644 Couch/gamedata/Couch.txt create mode 100644 Couch/scripting/Couch.sp create mode 100644 Couch/scripting/CouchAdmin.sp create mode 100644 Couch/scripting/CouchBan.sp create mode 100644 Couch/scripting/CouchHud.sp create mode 100644 Couch/scripting/include/Couch.inc diff --git a/Couch/gamedata/Couch.txt b/Couch/gamedata/Couch.txt new file mode 100644 index 0000000..c491b07 --- /dev/null +++ b/Couch/gamedata/Couch.txt @@ -0,0 +1,34 @@ +"Games" +{ + "cstrike" + { + "Offsets" + { + // CBaseEntity::ForceVPhysicsCollide(CBaseEntity *pEntity). Same offsets as PropSpawner. + "ForceVPhysicsCollide" + { + "linux" "160" + "windows" "159" + } + } + + "Signatures" + { + // CCollisionEvent::ShouldCollide(IPhysicsObject *, IPhysicsObject *, void *, void *). + // The vphysics collision filter, detoured for couch bans. Local symbol from .symtab, + // so the server binary must be unstripped (as for LagCompensation). + "CCollisionEvent::ShouldCollide" + { + "library" "server" + "linux" "@_ZN15CCollisionEvent13ShouldCollideEP14IPhysicsObjectS1_PvS2_" + } + + // CBaseEntity::CollisionRulesChanged(). Makes vphysics re-ask ShouldCollide for cached pairs. + "CBaseEntity::CollisionRulesChanged" + { + "library" "server" + "linux" "@_ZN11CBaseEntity21CollisionRulesChangedEv" + } + } + } +} diff --git a/Couch/scripting/Couch.sp b/Couch/scripting/Couch.sp new file mode 100644 index 0000000..ddf253a --- /dev/null +++ b/Couch/scripting/Couch.sp @@ -0,0 +1,892 @@ +#include +#include +#include +#include +#include +#include + +#define COUCH_CORE +#include + +#pragma semicolon 1 +#pragma newdecls required + +#define COUCH_DEFAULT_MODEL "models/props/cs_office/sofa.mdl" + +// Lifted off the traced surface; vphysics settles it. +#define COUCH_Z_OFFSET 10.0 + +// Fixed so it can be addressed with ent_fire. +#define COUCH_TARGETNAME "ze_couch" + +// Delay after the round-start respawn before the announcement. +#define COUCH_ANNOUNCE_DELAY 1.0 + +// game_text announcement +#define COUCH_TEXT_CHANNEL "3" +#define COUCH_TEXT_Y "0.1" +#define COUCH_TEXT_HOLD "8.0" +#define COUCH_TEXT_COLOUR "255 144 32" + +// Beacon timer interval, ticks per colour, ticks per light pulse. +#define COUCH_BEACON_INTERVAL 0.1 +#define COUCH_COLOUR_TICKS 4 +#define COUCH_PULSE_TICKS 10 + +// light_dynamic parented to the couch; the pulse is its radius breathing between these. +#define COUCH_LIGHT_HEIGHT 40.0 +#define COUCH_LIGHT_RADIUS_MIN 150.0 +#define COUCH_LIGHT_RADIUS_MAX 320.0 + +static const int g_iCouchColours[][3] = +{ + { 255, 48, 48 }, // red + { 255, 144, 32 }, // orange + { 255, 240, 64 }, // yellow + { 64, 255, 96 }, // green + { 64, 192, 255 }, // cyan + { 128, 96, 255 }, // violet + { 255, 80, 224 }, // magenta +}; + +// Allowed to move the couch in pistol-only mode, along with the knife. +static const char g_sPistols[][] = +{ + "weapon_glock", + "weapon_usp", + "weapon_p228", + "weapon_deagle", + "weapon_elite", + "weapon_fiveseven", +}; + +ConVar g_cvModel; +ConVar g_cvPushScale; +ConVar g_cvPistolOnly; +ConVar g_cvEffect; +ConVar g_cvIgnoreClip; +ConVar g_cvCollisionGroup; + +// Optional native from the UNLOZE playtime plugin; nobody is refused if it is absent. +// The plugin reports tier -1 until a player's hours have loaded. +ConVar g_cvTierRequirement; + +GlobalForward g_fwdOnCouchSpawned; +GlobalForward g_fwdOnCouchRemoved; +GlobalForward g_fwdOnCouchDamaged; +GlobalForward g_fwdOnClientBlocked; + +int g_iCouchRef = INVALID_ENT_REFERENCE; +int g_iLightRef = INVALID_ENT_REFERENCE; + +// Admin placement; the couch respawns here every round start. sm_bringcouch does not move it. +bool g_bPlaced; +float g_vecPlaceOrigin[3]; +float g_vecPlaceAngles[3]; + +Handle g_hBeaconTimer; +int g_iBeaconTick; + +Handle g_hForceVPhysicsCollide; + +// Couch bans: hits are refused in OnCouchTakeDamage, and the vphysics collision filter is +// detoured so a blocked player can't push it. The detour is only enabled while someone is blocked. +Handle g_hShouldCollideDetour; +Handle g_hCollisionRulesChanged; +bool g_bBlocked[MAXPLAYERS + 1]; +int g_iBlockedCount; +bool g_bDetourEnabled; + +public Plugin myinfo = +{ + name = "Couch", + author = "marisakirisame.net", + description = "ZE couch: spawns, respawns and lights the couch", + version = "2.0", + url = "https://marisakirisame.net" +}; + +// --------------------------------------------------------------------------------------------- +// Plugin lifecycle +// --------------------------------------------------------------------------------------------- + +public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max) +{ + CreateNative("Couch_GetCouch", Native_GetCouch); + CreateNative("Couch_IsPlaced", Native_IsPlaced); + CreateNative("Couch_Spawn", Native_Spawn); + CreateNative("Couch_Bring", Native_Bring); + CreateNative("Couch_Remove", Native_Remove); + CreateNative("Couch_IsEffectEnabled", Native_IsEffectEnabled); + CreateNative("Couch_SetEffectEnabled", Native_SetEffectEnabled); + CreateNative("Couch_IsPistolOnly", Native_IsPistolOnly); + CreateNative("Couch_SetPistolOnly", Native_SetPistolOnly); + CreateNative("Couch_SetClientBlocked", Native_SetClientBlocked); + CreateNative("Couch_IsClientBlocked", Native_IsClientBlocked); + RegPluginLibrary("Couch"); + + MarkNativeAsOptional("GetPlayerTier_native"); + + return APLRes_Success; +} + +public void OnPluginStart() +{ + g_fwdOnCouchSpawned = new GlobalForward("Couch_OnCouchSpawned", ET_Ignore, Param_Cell); + g_fwdOnCouchRemoved = new GlobalForward("Couch_OnCouchRemoved", ET_Ignore); + g_fwdOnCouchDamaged = new GlobalForward("Couch_OnCouchDamaged", ET_Ignore, Param_Cell, Param_Cell, Param_Float); + g_fwdOnClientBlocked = new GlobalForward("Couch_OnClientBlocked", ET_Ignore, Param_Cell, Param_Cell); + + g_cvModel = CreateConVar("sm_couch_model", COUCH_DEFAULT_MODEL, "Model used for the couch. Applies to the next spawn.", FCVAR_NOTIFY); + g_cvPushScale = CreateConVar("sm_couch_push_scale", "1.0", "Multiplier on the force bullets apply to the couch. 1.0 is the engine's own push.", FCVAR_NOTIFY, true, 0.0, true, 100.0); + g_cvPistolOnly = CreateConVar("sm_couch_pistol_only", "0", "Only pistols and knives move the couch; other guns and grenades are ignored.", FCVAR_NOTIFY, true, 0.0, true, 1.0); + g_cvEffect = CreateConVar("sm_couch_effect", "0", "Colour cycle and pulsing light on the couch.", FCVAR_NOTIFY, true, 0.0, true, 1.0); + g_cvIgnoreClip = CreateConVar("sm_couch_ignore_clip", "1", "Let the couch pass through func_clip_vphysics brushes.", FCVAR_NOTIFY, true, 0.0, true, 1.0); + g_cvTierRequirement = CreateConVar("sm_couch_tier_requirement", "0", "Minimum playtime tier needed to shoot the couch.", FCVAR_NOTIFY, true, 0.0); + g_cvCollisionGroup = CreateConVar("sm_couch_collision_group", "5", "Collision group given to the couch at spawn. 5 (player), 0 (prop)", FCVAR_NOTIFY, true, 0.0, true, 20.0); + + g_cvModel.AddChangeHook(OnModelChanged); + g_cvEffect.AddChangeHook(OnEffectChanged); + + AutoExecConfig(true, "Couch"); + + HookEvent("round_start", Event_RoundStart, EventHookMode_Post); + + LoadGameData(); + + // Late load: the clip brushes already exist, so hook them now. + if (g_hForceVPhysicsCollide != null) + { + int entity = -1; + while ((entity = FindEntityByClassname(entity, "func_clip_vphysics")) != -1) + DHookEntity(g_hForceVPhysicsCollide, false, entity); + } +} + +public void OnPluginEnd() +{ + // Don't leave a couch with a dead damage hook and an orphaned light behind. + KillCouch(); +} + +public void OnMapStart() +{ + PrecacheCouchModel(); + + // Entities and no-mapchange timers went with the map. + g_iCouchRef = INVALID_ENT_REFERENCE; + g_iLightRef = INVALID_ENT_REFERENCE; + g_bPlaced = false; + g_hBeaconTimer = null; + g_iBeaconTick = 0; + + ResetBlockedClients(); +} + +public void OnEntityCreated(int entity, const char[] classname) +{ + // The cvar is checked in the callback so it can be flipped at runtime. + if (g_hForceVPhysicsCollide != null && StrEqual(classname, "func_clip_vphysics")) + DHookEntity(g_hForceVPhysicsCollide, false, entity); +} + +public void OnEntityDestroyed(int entity) +{ + if (g_iCouchRef == INVALID_ENT_REFERENCE || entity != EntRefToEntIndex(g_iCouchRef)) + return; + + // Removed by something else (round restart purge, trigger_remove, ent_fire). Stays gone + // until the next round start. + g_iCouchRef = INVALID_ENT_REFERENCE; + KillLight(); + StopBeacon(); + + Call_StartForward(g_fwdOnCouchRemoved); + Call_Finish(); +} + +// Slot flag only; persistence across reconnects is the ban plugin's job. +public void OnClientDisconnect(int client) +{ + if (g_bBlocked[client]) + SetClientBlocked(client, false); +} + +public void Event_RoundStart(Event event, const char[] name, bool dontBroadcast) +{ + if (!g_bPlaced || GetCouchIndex() != -1) + return; + + if (SpawnCouchAt(g_vecPlaceOrigin, g_vecPlaceAngles) == -1) + return; + + // Delayed so the round-start HUD reset doesn't wipe it. + CreateTimer(COUCH_ANNOUNCE_DELAY, Timer_AnnounceCouchRound, _, TIMER_FLAG_NO_MAPCHANGE); +} + +public Action Timer_AnnounceCouchRound(Handle timer) +{ + if (GetCouchIndex() == -1) + return Plugin_Stop; + + ShowCouchRoundText(); + PrintToChatAll("\x01[SM] \x04Couch round!\x01 Humans must bring the couch with them throughout the map or they fail the round."); + + return Plugin_Stop; +} + +// game_text to everyone, killed straight after Display; the message has already gone out. +void ShowCouchRoundText() +{ + int text = CreateEntityByName("game_text"); + if (text == -1) + return; + + DispatchKeyValue(text, "targetname", COUCH_TARGETNAME ... "_text"); + DispatchKeyValue(text, "spawnflags", "1"); // All players. + DispatchKeyValue(text, "channel", COUCH_TEXT_CHANNEL); + DispatchKeyValue(text, "x", "-1"); // Centred. + DispatchKeyValue(text, "y", COUCH_TEXT_Y); + DispatchKeyValue(text, "effect", "0"); // Fade in and out. + DispatchKeyValue(text, "color", COUCH_TEXT_COLOUR); + DispatchKeyValue(text, "color2", "255 255 255"); + DispatchKeyValue(text, "fadein", "0.5"); + DispatchKeyValue(text, "fadeout", "3.0"); + DispatchKeyValue(text, "holdtime", COUCH_TEXT_HOLD); + DispatchKeyValue(text, "fxtime", "0.25"); + DispatchKeyValue(text, "message", "COUCH ROUND!\nHumans must bring the couch with them\nthroughout the map else they will fail the round."); + DispatchSpawn(text); + + AcceptEntityInput(text, "Display"); + AcceptEntityInput(text, "Kill"); +} + +// --------------------------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------------------------- + +public void OnModelChanged(ConVar convar, const char[] oldValue, const char[] newValue) +{ + PrecacheCouchModel(); +} + +void PrecacheCouchModel() +{ + char sModel[PLATFORM_MAX_PATH]; + GetCouchModel(sModel, sizeof(sModel)); + PrecacheModel(sModel, true); +} + +// Falls back to the default if the cvar's model doesn't exist; spawning with a missing model is a hard error. +void GetCouchModel(char[] buffer, int maxlen) +{ + g_cvModel.GetString(buffer, maxlen); + + if (buffer[0] != '\0' && FileExists(buffer, true)) + return; + + if (buffer[0] != '\0') + LogError("sm_couch_model \"%s\" does not exist, using %s", buffer, COUCH_DEFAULT_MODEL); + + strcopy(buffer, maxlen, COUCH_DEFAULT_MODEL); +} + +// --------------------------------------------------------------------------------------------- +// Couch entity +// --------------------------------------------------------------------------------------------- + +int GetCouchIndex() +{ + if (g_iCouchRef == INVALID_ENT_REFERENCE) + return -1; + + int couch = EntRefToEntIndex(g_iCouchRef); + if (couch == INVALID_ENT_REFERENCE || !IsValidEntity(couch)) + return -1; + + return couch; +} + +int GetLightIndex() +{ + if (g_iLightRef == INVALID_ENT_REFERENCE) + return -1; + + int light = EntRefToEntIndex(g_iLightRef); + if (light == INVALID_ENT_REFERENCE || !IsValidEntity(light)) + return -1; + + return light; +} + +// Crosshair hit position: upright, facing the client's way, lifted off the surface. +void TraceCrosshair(int client, float vecOrigin[3], float vecAngles[3]) +{ + GetClientEyePosition(client, vecOrigin); + GetClientEyeAngles(client, vecAngles); + TR_TraceRayFilter(vecOrigin, vecAngles, MASK_SOLID, RayType_Infinite, TraceFilter_NotSelfOrCouch, client); + TR_GetEndPosition(vecOrigin); + + vecOrigin[2] += COUCH_Z_OFFSET; + vecAngles[0] = 0.0; + vecAngles[2] = 0.0; +} + +// Skips the couch too, so bringing it while looking at it doesn't stack it on itself. +bool TraceFilter_NotSelfOrCouch(int entity, int contentsMask, any client) +{ + return entity != client && entity != GetCouchIndex(); +} + +// The only place the couch is created. Returns the entity or -1. +int SpawnCouchAt(const float vecOrigin[3], const float vecAngles[3]) +{ + int couch = CreateEntityByName("prop_physics_override"); + if (couch == -1) + return -1; + + char sModel[PLATFORM_MAX_PATH]; + GetCouchModel(sModel, sizeof(sModel)); + if (!IsModelPrecached(sModel)) + PrecacheModel(sModel, true); + + DispatchKeyValue(couch, "targetname", COUCH_TARGETNAME); + DispatchKeyValue(couch, "model", sModel); + SetEntProp(couch, Prop_Data, "m_CollisionGroup", g_cvCollisionGroup.IntValue); + + TeleportEntity(couch, vecOrigin, vecAngles, NULL_VECTOR); + DispatchSpawn(couch); + + // FL_CLIENT and a team let the couch trip "Clients only" and team-filtered triggers. + SetEntityFlags(couch, FL_CLIENT); + SetEntProp(couch, Prop_Data, "m_iTeamNum", CS_TEAM_CT); + + // SDKHooks drops the hook with the entity. + SDKHook(couch, SDKHook_OnTakeDamage, OnCouchTakeDamage); + + // Takes the beacon's colour modulation at full opacity. + SetEntityRenderMode(couch, RENDER_NORMAL); + + g_iCouchRef = EntIndexToEntRef(couch); + + ApplyEffectState(); + + Call_StartForward(g_fwdOnCouchSpawned); + Call_PushCell(couch); + Call_Finish(); + + return couch; +} + +// Returns true if there was a couch to remove. +bool KillCouch() +{ + int couch = GetCouchIndex(); + + // Cleared first so OnEntityDestroyed doesn't run the external-removal path too. + g_iCouchRef = INVALID_ENT_REFERENCE; + StopBeacon(); + KillLight(); + + if (couch == -1) + return false; + + AcceptEntityInput(couch, "Kill"); + + Call_StartForward(g_fwdOnCouchRemoved); + Call_Finish(); + + return true; +} + +// --------------------------------------------------------------------------------------------- +// Damage +// --------------------------------------------------------------------------------------------- + +// Plugin_Handled skips the engine's impulse too, so a refused hit doesn't nudge the couch. +public Action OnCouchTakeDamage(int victim, int &attacker, int &inflictor, float &damage, int &damagetype, int &weapon, float damageForce[3], float damagePosition[3], int damagecustom) +{ + bool bFromClient = (attacker >= 1 && attacker <= MaxClients && IsClientInGame(attacker)); + + // Couch-banned: the ban plugin gets a forward so it can tell them why. + if (bFromClient && g_bBlocked[attacker]) + { + Call_StartForward(g_fwdOnClientBlocked); + Call_PushCell(victim); + Call_PushCell(attacker); + Call_Finish(); + + return Plugin_Handled; + } + + if (bFromClient && !MeetsTierRequirement(attacker)) + { + PrintHintText(attacker, "You need to be tier %d or higher to shoot the couch.", g_cvTierRequirement.IntValue); + return Plugin_Handled; + } + + if (bFromClient && g_cvPistolOnly.BoolValue && !IsPistolOrKnifeHit(attacker, weapon, damagetype)) + return Plugin_Handled; + + Action result = Plugin_Continue; + + float fScale = g_cvPushScale.FloatValue; + if (fScale != 1.0) + { + ScaleVector(damageForce, fScale); + result = Plugin_Changed; + } + + if (bFromClient) + { + Call_StartForward(g_fwdOnCouchDamaged); + Call_PushCell(victim); + Call_PushCell(attacker); + Call_PushFloat(damage); + Call_Finish(); + } + + return result; +} + +// Without the playtime plugin nobody is refused. +bool MeetsTierRequirement(int client) +{ + int required = g_cvTierRequirement.IntValue; + if (required <= 0) + return true; + + if (GetFeatureStatus(FeatureType_Native, "GetPlayerTier_native") != FeatureStatus_Available) + return true; + + return GetPlayerTier_native(client) >= required; +} + +bool IsPistolOrKnifeHit(int attacker, int weapon, int damagetype) +{ + // Knife is slash/club; nothing else a player carries deals either. + if (damagetype & (DMG_SLASH | DMG_CLUB)) + return true; + + // Grenades are blast, so only bullets from here on. + if (!(damagetype & DMG_BULLET)) + return false; + + // CS:S rarely names the weapon in bullet damage, so fall back to the active weapon. + if (weapon <= MaxClients || !IsValidEntity(weapon)) + weapon = GetEntPropEnt(attacker, Prop_Send, "m_hActiveWeapon"); + + if (weapon == -1) + return false; + + char sClass[32]; + GetEntityClassname(weapon, sClass, sizeof(sClass)); + + // In case a knife hit ever arrives typed as a bullet. + if (StrEqual(sClass, "weapon_knife")) + return true; + + for (int i = 0; i < sizeof(g_sPistols); i++) + { + if (StrEqual(sClass, g_sPistols[i])) + return true; + } + + return false; +} + +// --------------------------------------------------------------------------------------------- +// func_clip_vphysics +// --------------------------------------------------------------------------------------------- + +void LoadGameData() +{ + GameData gd = new GameData("Couch"); + if (gd == null) + { + LogError("Missing gamedata/Couch.txt; the couch will collide with func_clip_vphysics and couch-banned players can still push it."); + return; + } + + int offset = gd.GetOffset("ForceVPhysicsCollide"); + if (offset == -1) + { + LogError("Missing \"ForceVPhysicsCollide\" offset in gamedata/Couch.txt; the couch will collide with func_clip_vphysics."); + } + else + { + g_hForceVPhysicsCollide = DHookCreate(offset, HookType_Entity, ReturnType_Bool, ThisPointer_CBaseEntity, Hook_ForceVPhysicsCollide); + DHookAddParam(g_hForceVPhysicsCollide, HookParamType_CBaseEntity); + } + + // Collision filter detour for couch bans; enabled only while someone is blocked. + g_hShouldCollideDetour = DHookCreateDetour(Address_Null, CallConv_THISCALL, ReturnType_Int, ThisPointer_Address); + if (!DHookSetFromConf(g_hShouldCollideDetour, gd, SDKConf_Signature, "CCollisionEvent::ShouldCollide")) + { + LogError("Missing \"CCollisionEvent::ShouldCollide\" signature in gamedata/Couch.txt; couch-banned players can still push the couch."); + delete g_hShouldCollideDetour; + } + else + { + DHookAddParam(g_hShouldCollideDetour, HookParamType_Int); // IPhysicsObject *pObj0 + DHookAddParam(g_hShouldCollideDetour, HookParamType_Int); // IPhysicsObject *pObj1 + DHookAddParam(g_hShouldCollideDetour, HookParamType_CBaseEntity); // void *pGameData0: entity behind pObj0 + DHookAddParam(g_hShouldCollideDetour, HookParamType_CBaseEntity); // void *pGameData1: entity behind pObj1 + } + + // Makes vphysics re-ask ShouldCollide for existing pairs, so a ban applies while the player + // is already touching the couch. + StartPrepSDKCall(SDKCall_Entity); + if (PrepSDKCall_SetFromConf(gd, SDKConf_Signature, "CBaseEntity::CollisionRulesChanged")) + g_hCollisionRulesChanged = EndPrepSDKCall(); + + if (g_hCollisionRulesChanged == null) + LogError("Missing \"CBaseEntity::CollisionRulesChanged\" signature in gamedata/Couch.txt; couch bans stop pushing only once the player and couch separate."); + + delete gd; +} + +// Lets the couch pass through func_clip_vphysics. +public MRESReturn Hook_ForceVPhysicsCollide(int pThis, Handle hReturn, Handle hParams) +{ + if (!g_cvIgnoreClip.BoolValue) + return MRES_Ignored; + + int entity = DHookGetParam(hParams, 1); + if (entity == -1 || entity != GetCouchIndex()) + return MRES_Ignored; + + DHookSetReturn(hReturn, false); + return MRES_Supercede; +} + +// --------------------------------------------------------------------------------------------- +// Couch ban +// --------------------------------------------------------------------------------------------- + +void SetClientBlocked(int client, bool blocked) +{ + if (g_bBlocked[client] == blocked) + return; + + g_bBlocked[client] = blocked; + g_iBlockedCount += blocked ? 1 : -1; + + // Detour goes on before the recheck so vphysics sees the new rule. + if (g_iBlockedCount > 0) + EnableCollisionDetour(); + else + DisableCollisionDetour(); + + RecheckCouchCollision(client); +} + +void ResetBlockedClients() +{ + for (int i = 1; i <= MAXPLAYERS; i++) + g_bBlocked[i] = false; + + g_iBlockedCount = 0; + DisableCollisionDetour(); +} + +void EnableCollisionDetour() +{ + if (g_hShouldCollideDetour == null || g_bDetourEnabled) + return; + + if (!DHookEnableDetour(g_hShouldCollideDetour, false, Detour_ShouldCollide)) + { + LogError("Failed to detour CCollisionEvent::ShouldCollide; couch-banned players can still push the couch."); + return; + } + + g_bDetourEnabled = true; +} + +void DisableCollisionDetour() +{ + if (!g_bDetourEnabled) + return; + + DHookDisableDetour(g_hShouldCollideDetour, false, Detour_ShouldCollide); + g_bDetourEnabled = false; +} + +// vphysics caches the ShouldCollide answer per pair until the objects separate, so both halves +// of the pair are rechecked. +void RecheckCouchCollision(int client) +{ + if (g_hCollisionRulesChanged == null) + return; + + int couch = GetCouchIndex(); + if (couch != -1) + SDKCall(g_hCollisionRulesChanged, couch); + + if (client >= 1 && client <= MaxClients && IsClientInGame(client)) + SDKCall(g_hCollisionRulesChanged, client); +} + +// A blocked player's physics shadow never touches the couch. Movement traces are separate, so +// the couch stays solid to them: they can stand on it, just not push it. +public MRESReturn Detour_ShouldCollide(Address pThis, Handle hReturn, Handle hParams) +{ + int couch = GetCouchIndex(); + if (couch == -1) + return MRES_Ignored; + + // -1 when the physics object has no entity behind it. + int ent0 = DHookGetParam(hParams, 3); + int ent1 = DHookGetParam(hParams, 4); + + int other; + if (ent0 == couch) + other = ent1; + else if (ent1 == couch) + other = ent0; + else + return MRES_Ignored; + + if (other < 1 || other > MaxClients || !g_bBlocked[other]) + return MRES_Ignored; + + DHookSetReturn(hReturn, 0); + return MRES_Supercede; +} + +// --------------------------------------------------------------------------------------------- +// Effect +// --------------------------------------------------------------------------------------------- + +public void OnEffectChanged(ConVar convar, const char[] oldValue, const char[] newValue) +{ + ApplyEffectState(); +} + +// Brings the light and beacon in line with sm_couch_effect. Safe to call at any time. +void ApplyEffectState() +{ + int couch = GetCouchIndex(); + + if (couch == -1 || !g_cvEffect.BoolValue) + { + StopBeacon(); + KillLight(); + + if (couch != -1) + SetEntityRenderColor(couch, 255, 255, 255, 255); + + return; + } + + if (GetLightIndex() == -1) + g_iLightRef = SpawnCouchLight(couch); + + StartBeacon(); +} + +// light_dynamic parented to the couch. Returns INVALID_ENT_REFERENCE on failure. +int SpawnCouchLight(int couch) +{ + int light = CreateEntityByName("light_dynamic"); + if (light == -1) + return INVALID_ENT_REFERENCE; + + DispatchKeyValue(light, "targetname", COUCH_TARGETNAME ... "_light"); + DispatchKeyValue(light, "spawnflags", "0"); // Light the world and models both. + DispatchKeyValue(light, "style", "0"); + DispatchKeyValue(light, "pitch", "-90"); + DispatchKeyValue(light, "angles", "0 0 0"); + DispatchKeyValue(light, "_light", "255 255 255 255"); + DispatchKeyValue(light, "brightness", "3"); + DispatchKeyValue(light, "distance", "130"); + DispatchKeyValue(light, "spotlight_radius", "70"); + + // Zero cones make it a point light, so it doesn't swing off when the couch tips over. + DispatchKeyValue(light, "_inner_cone", "0"); + DispatchKeyValue(light, "_cone", "0"); + + float vecOrigin[3]; + GetEntPropVector(couch, Prop_Send, "m_vecOrigin", vecOrigin); + vecOrigin[2] += COUCH_LIGHT_HEIGHT; + TeleportEntity(light, vecOrigin, NULL_VECTOR, NULL_VECTOR); + + DispatchSpawn(light); + ActivateEntity(light); + + // Parenting keeps the position set above. + SetVariantString("!activator"); + AcceptEntityInput(light, "SetParent", couch, couch); + + return EntIndexToEntRef(light); +} + +// Source doesn't remove children with their parent. +void KillLight() +{ + int light = GetLightIndex(); + g_iLightRef = INVALID_ENT_REFERENCE; + + if (light != -1) + AcceptEntityInput(light, "Kill"); +} + +void StartBeacon() +{ + if (g_hBeaconTimer == null) + g_hBeaconTimer = CreateTimer(COUCH_BEACON_INTERVAL, Timer_Beacon, _, TIMER_REPEAT | TIMER_FLAG_NO_MAPCHANGE); +} + +// Not called from inside Timer_Beacon; that returns Plugin_Stop instead. +void StopBeacon() +{ + delete g_hBeaconTimer; +} + +public Action Timer_Beacon(Handle timer) +{ + int couch = GetCouchIndex(); + if (couch == -1) + { + g_hBeaconTimer = null; + return Plugin_Stop; + } + + g_iBeaconTick++; + + int colour = (g_iBeaconTick / COUCH_COLOUR_TICKS) % sizeof(g_iCouchColours); + int r = g_iCouchColours[colour][0]; + int g = g_iCouchColours[colour][1]; + int b = g_iCouchColours[colour][2]; + + SetEntityRenderColor(couch, r, g, b, 255); + + int light = GetLightIndex(); + if (light == -1) + return Plugin_Continue; + + SetEntityRenderColor(light, r, g, b, 255); + + // One sine period per pulse cycle, mapped onto the radius range. + float fPhase = float(g_iBeaconTick % COUCH_PULSE_TICKS) / float(COUCH_PULSE_TICKS); + float fPulse = (Sine(fPhase * 2.0 * FLOAT_PI) + 1.0) / 2.0; + + SetVariantFloat(COUCH_LIGHT_RADIUS_MIN + fPulse * (COUCH_LIGHT_RADIUS_MAX - COUCH_LIGHT_RADIUS_MIN)); + AcceptEntityInput(light, "distance"); + + return Plugin_Continue; +} + +// --------------------------------------------------------------------------------------------- +// Natives +// --------------------------------------------------------------------------------------------- + +public any Native_GetCouch(Handle plugin, int numParams) +{ + return GetCouchIndex(); +} + +public any Native_IsPlaced(Handle plugin, int numParams) +{ + return g_bPlaced; +} + +public any Native_Spawn(Handle plugin, int numParams) +{ + int client = GetNativeCell(1); + if (client < 1 || client > MaxClients || !IsClientInGame(client)) + return ThrowNativeError(SP_ERROR_NATIVE, "Client %d is not in game", client); + + float vecOrigin[3]; + float vecAngles[3]; + TraceCrosshair(client, vecOrigin, vecAngles); + + // Replaced rather than moved so a changed sm_couch_model applies. + KillCouch(); + + int couch = SpawnCouchAt(vecOrigin, vecAngles); + if (couch == -1) + return -1; + + g_bPlaced = true; + g_vecPlaceOrigin = vecOrigin; + g_vecPlaceAngles = vecAngles; + + return couch; +} + +public any Native_Bring(Handle plugin, int numParams) +{ + int client = GetNativeCell(1); + if (client < 1 || client > MaxClients || !IsClientInGame(client)) + return ThrowNativeError(SP_ERROR_NATIVE, "Client %d is not in game", client); + + int couch = GetCouchIndex(); + if (couch == -1) + return false; + + float vecOrigin[3]; + float vecAngles[3]; + TraceCrosshair(client, vecOrigin, vecAngles); + + // Arrives still, then woken so vphysics drops it onto the surface. The light follows its parent. + float vecStill[3]; + TeleportEntity(couch, vecOrigin, vecAngles, vecStill); + AcceptEntityInput(couch, "Wake"); + + // Not recorded as the placement; next round it returns to where it was spawned. + return true; +} + +public any Native_Remove(Handle plugin, int numParams) +{ + bool bHadSomething = g_bPlaced || GetCouchIndex() != -1; + + KillCouch(); + g_bPlaced = false; + + return bHadSomething; +} + +public any Native_IsEffectEnabled(Handle plugin, int numParams) +{ + return g_cvEffect.BoolValue; +} + +// The cvar's change hook applies the state, so console and menu do the same thing. +public any Native_SetEffectEnabled(Handle plugin, int numParams) +{ + g_cvEffect.SetBool(view_as(GetNativeCell(1))); + return 0; +} + +public any Native_IsPistolOnly(Handle plugin, int numParams) +{ + return g_cvPistolOnly.BoolValue; +} + +public any Native_SetPistolOnly(Handle plugin, int numParams) +{ + g_cvPistolOnly.SetBool(view_as(GetNativeCell(1))); + return 0; +} + +public any Native_SetClientBlocked(Handle plugin, int numParams) +{ + int client = GetNativeCell(1); + if (client < 1 || client > MaxClients || !IsClientInGame(client)) + return ThrowNativeError(SP_ERROR_NATIVE, "Client %d is not in game", client); + + SetClientBlocked(client, view_as(GetNativeCell(2))); + return 0; +} + +public any Native_IsClientBlocked(Handle plugin, int numParams) +{ + int client = GetNativeCell(1); + if (client < 1 || client > MaxClients) + return false; + + return g_bBlocked[client]; +} diff --git a/Couch/scripting/CouchAdmin.sp b/Couch/scripting/CouchAdmin.sp new file mode 100644 index 0000000..944d3a7 --- /dev/null +++ b/Couch/scripting/CouchAdmin.sp @@ -0,0 +1,250 @@ +#include +#include + +#pragma semicolon 1 +#pragma newdecls required + +#define PREFIX "\x01[SM] \x04" + +// So commands fail with a message instead of an unbound-native error while the core is unloaded. +bool g_bCoreLoaded; + +public Plugin myinfo = +{ + name = "Couch Admin", + author = "marisakirisame.net", + description = "Admin commands and menu for the ZE couch", + version = "2.0", + url = "https://marisakirisame.net" +}; + +public void OnPluginStart() +{ + RegAdminCmd("sm_couch", Command_Couch, ADMFLAG_CHANGEMAP, "Opens the couch menu"); + RegAdminCmd("sm_spawncouch", Command_SpawnCouch, ADMFLAG_CHANGEMAP, "Spawns the couch where you are aiming"); + RegAdminCmd("sm_bringcouch", Command_BringCouch, ADMFLAG_CHANGEMAP, "Brings the couch to where you are aiming"); + RegAdminCmd("sm_removecouch", Command_RemoveCouch, ADMFLAG_CHANGEMAP, "Removes the couch"); + RegAdminCmd("sm_couchpistolonly", Command_PistolOnly, ADMFLAG_CHANGEMAP, "sm_couchpistolonly [0|1] - only pistols and knives move the couch; no argument toggles"); +} + +public void OnAllPluginsLoaded() +{ + g_bCoreLoaded = LibraryExists("Couch"); +} + +public void OnLibraryAdded(const char[] name) +{ + if (StrEqual(name, "Couch")) + g_bCoreLoaded = true; +} + +public void OnLibraryRemoved(const char[] name) +{ + if (StrEqual(name, "Couch")) + g_bCoreLoaded = false; +} + +// --------------------------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------------------------- + +public Action Command_Couch(int client, int args) +{ + if (!RequireInGame(client) || !RequireCore(client)) + return Plugin_Handled; + + ShowCouchMenu(client); + return Plugin_Handled; +} + +public Action Command_SpawnCouch(int client, int args) +{ + if (!RequireInGame(client) || !RequireCore(client)) + return Plugin_Handled; + + DoSpawn(client); + return Plugin_Handled; +} + +public Action Command_BringCouch(int client, int args) +{ + if (!RequireInGame(client) || !RequireCore(client)) + return Plugin_Handled; + + DoBring(client); + return Plugin_Handled; +} + +// Needs no position, so it also works from the server console. +public Action Command_RemoveCouch(int client, int args) +{ + if (!RequireCore(client)) + return Plugin_Handled; + + DoRemove(client); + return Plugin_Handled; +} + +public Action Command_PistolOnly(int client, int args) +{ + if (!RequireCore(client)) + return Plugin_Handled; + + bool bEnable = !Couch_IsPistolOnly(); + if (args >= 1) + bEnable = (GetCmdArgInt(1) != 0); + + DoSetPistolOnly(client, bEnable); + return Plugin_Handled; +} + +bool RequireInGame(int client) +{ + if (client >= 1 && IsClientInGame(client)) + return true; + + ReplyToCommand(client, "[SM] This command can only be used in-game."); + return false; +} + +bool RequireCore(int client) +{ + if (g_bCoreLoaded) + return true; + + ReplyToCommand(client, "[SM] The Couch core plugin is not loaded."); + return false; +} + +// --------------------------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------------------------- + +void DoSpawn(int client) +{ + if (Couch_Spawn(client) == -1) + { + ReplyToCommand(client, "[SM] Could not create the couch."); + return; + } + + ShowActivity2(client, PREFIX, "\x01Spawned the \x04couch\x01."); + LogAction(client, -1, "\"%L\" spawned the couch.", client); +} + +void DoBring(int client) +{ + if (!Couch_Bring(client)) + { + ReplyToCommand(client, "[SM] There is no couch to bring. Use sm_spawncouch first."); + return; + } + + ShowActivity2(client, PREFIX, "\x01Brought the \x04couch\x01."); + LogAction(client, -1, "\"%L\" brought the couch.", client); +} + +void DoRemove(int client) +{ + if (!Couch_Remove()) + { + ReplyToCommand(client, "[SM] There is no couch to remove."); + return; + } + + ShowActivity2(client, PREFIX, "\x01Removed the \x04couch\x01."); + LogAction(client, -1, "\"%L\" removed the couch.", client); +} + +void DoToggleEffect(int client) +{ + bool bEnable = !Couch_IsEffectEnabled(); + Couch_SetEffectEnabled(bEnable); + + ShowActivity2(client, PREFIX, "\x01Turned the couch effect \x04%s\x01.", bEnable ? "on" : "off"); + LogAction(client, -1, "\"%L\" turned the couch effect %s.", client, bEnable ? "on" : "off"); +} + +// Announced to everyone so players know why their rifle stopped moving the couch. +void DoSetPistolOnly(int client, bool bEnable) +{ + Couch_SetPistolOnly(bEnable); + + ShowActivity2(client, PREFIX, "\x01Turned couch pistol-only mode \x04%s\x01.", bEnable ? "on" : "off"); + LogAction(client, -1, "\"%L\" turned couch pistol-only mode %s.", client, bEnable ? "on" : "off"); +} + +// --------------------------------------------------------------------------------------------- +// Menu +// --------------------------------------------------------------------------------------------- + +// Rebuilt on every display so the labels always reflect the current state. +void ShowCouchMenu(int client) +{ + bool bHasCouch = (Couch_GetCouch() != -1); + bool bPlaced = Couch_IsPlaced(); + + Menu menu = new Menu(MenuHandler_Couch); + menu.SetTitle("Couch"); + + // With a couch out, bring is the way to move it; without one there is nothing to bring. + menu.AddItem("spawn", "Spawn couch at crosshair", bHasCouch ? ITEMDRAW_DISABLED : ITEMDRAW_DEFAULT); + menu.AddItem("bring", "Bring couch to crosshair", bHasCouch ? ITEMDRAW_DEFAULT : ITEMDRAW_DISABLED); + + if (bHasCouch) + menu.AddItem("remove", "Remove couch"); + else if (bPlaced) + menu.AddItem("remove", "Remove couch (respawns next round)"); + else + menu.AddItem("remove", "Remove couch", ITEMDRAW_DISABLED); + + char sItem[64]; + FormatEx(sItem, sizeof(sItem), "Effect: %s", Couch_IsEffectEnabled() ? "ON" : "OFF"); + menu.AddItem("effect", sItem); + + FormatEx(sItem, sizeof(sItem), "Pistol only: %s", Couch_IsPistolOnly() ? "ON" : "OFF"); + menu.AddItem("pistol", sItem); + + menu.ExitButton = true; + menu.Display(client, MENU_TIME_FOREVER); +} + +public int MenuHandler_Couch(Menu menu, MenuAction action, int param1, int param2) +{ + switch (action) + { + case MenuAction_Select: + { + int client = param1; + + if (!g_bCoreLoaded) + { + RequireCore(client); + return 0; + } + + char sInfo[16]; + menu.GetItem(param2, sInfo, sizeof(sInfo)); + + if (StrEqual(sInfo, "spawn")) + DoSpawn(client); + else if (StrEqual(sInfo, "bring")) + DoBring(client); + else if (StrEqual(sInfo, "remove")) + DoRemove(client); + else if (StrEqual(sInfo, "effect")) + DoToggleEffect(client); + else if (StrEqual(sInfo, "pistol")) + DoSetPistolOnly(client, !Couch_IsPistolOnly()); + + ShowCouchMenu(client); + } + + case MenuAction_End: + { + delete menu; + } + } + + return 0; +} diff --git a/Couch/scripting/CouchBan.sp b/Couch/scripting/CouchBan.sp new file mode 100644 index 0000000..79db6a9 --- /dev/null +++ b/Couch/scripting/CouchBan.sp @@ -0,0 +1,246 @@ +#include +#include + +#pragma semicolon 1 +#pragma newdecls required + +#define PREFIX "\x01[SM] \x04" + +// So commands fail with a message instead of an unbound-native error while the core is unloaded. +bool g_bCoreLoaded; + +// Account ids banned this map. This is the ban itself, so leaving and rejoining does not clear it. +ArrayList g_alBannedAccounts; + +// Slot mirror. Also carries bans of unauthenticated clients (account id 0), which last the session only. +bool g_bBanned[MAXPLAYERS + 1]; + +public Plugin myinfo = +{ + name = "Couch Ban", + author = "marisakirisame.net", + description = "Bans players from the ZE couch until the end of the map", + version = "2.0", + url = "https://marisakirisame.net" +}; + +// --------------------------------------------------------------------------------------------- +// Plugin lifecycle +// --------------------------------------------------------------------------------------------- + +public void OnPluginStart() +{ + LoadTranslations("common.phrases"); // FindTarget's replies. + + g_alBannedAccounts = new ArrayList(); + + RegAdminCmd("sm_couchban", Command_CouchBan, ADMFLAG_BAN, "sm_couchban <#userid|name> - bans a player from the couch until the end of the map"); + RegAdminCmd("sm_cban", Command_CouchBan, ADMFLAG_BAN, "Alias of sm_couchban"); + RegAdminCmd("sm_couchunban", Command_CouchUnban, ADMFLAG_BAN, "sm_couchunban <#userid|name> - lifts a couch ban"); + RegAdminCmd("sm_cunban", Command_CouchUnban, ADMFLAG_BAN, "Alias of sm_couchunban"); +} + +public void OnAllPluginsLoaded() +{ + g_bCoreLoaded = LibraryExists("Couch"); + + // After a reload of this plugin the core still knows who is blocked. + if (!g_bCoreLoaded) + return; + + for (int i = 1; i <= MaxClients; i++) + { + if (!IsClientInGame(i) || !Couch_IsClientBlocked(i)) + continue; + + g_bBanned[i] = true; + RememberAccount(i); + } +} + +public void OnLibraryAdded(const char[] name) +{ + if (!StrEqual(name, "Couch")) + return; + + g_bCoreLoaded = true; + + // A core reload wiped its flags. A frame later so its natives are bound. + RequestFrame(Frame_ReapplyBans); +} + +public void OnLibraryRemoved(const char[] name) +{ + if (StrEqual(name, "Couch")) + g_bCoreLoaded = false; +} + +void Frame_ReapplyBans(any data) +{ + if (!g_bCoreLoaded) + return; + + for (int i = 1; i <= MaxClients; i++) + { + if (g_bBanned[i] && IsClientInGame(i)) + Couch_SetClientBlocked(i, true); + } +} + +public void OnMapStart() +{ + for (int i = 1; i <= MAXPLAYERS; i++) + g_bBanned[i] = false; +} + +public void OnMapEnd() +{ + g_alBannedAccounts.Clear(); +} + +// The account id stays on the list so a rejoin picks the ban back up. +public void OnClientDisconnect(int client) +{ + g_bBanned[client] = false; +} + +// Either "in game" or "authorized" can come last, so both are hooked. +public void OnClientPutInServer(int client) +{ + TryReapplyBan(client); +} + +public void OnClientAuthorized(int client, const char[] auth) +{ + TryReapplyBan(client); +} + +void TryReapplyBan(int client) +{ + if (g_bBanned[client] || !IsClientInGame(client)) + return; + + int account = GetSteamAccountID(client); + if (account == 0 || g_alBannedAccounts.FindValue(account) == -1) + return; + + g_bBanned[client] = true; + + if (g_bCoreLoaded) + Couch_SetClientBlocked(client, true); + + PrintToChat(client, PREFIX ... "You are couch-banned\x01 until the end of the map."); +} + +// --------------------------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------------------------- + +public Action Command_CouchBan(int client, int args) +{ + if (!RequireCore(client)) + return Plugin_Handled; + + if (args < 1) + { + ReplyToCommand(client, "[SM] Usage: sm_couchban <#userid|name>"); + return Plugin_Handled; + } + + char sArg[MAX_TARGET_LENGTH]; + GetCmdArg(1, sArg, sizeof(sArg)); + + int target = FindTarget(client, sArg, true, true); + if (target == -1) + return Plugin_Handled; + + if (g_bBanned[target]) + { + ReplyToCommand(client, "[SM] %N is already couch-banned.", target); + return Plugin_Handled; + } + + DoBan(client, target); + return Plugin_Handled; +} + +public Action Command_CouchUnban(int client, int args) +{ + if (!RequireCore(client)) + return Plugin_Handled; + + if (args < 1) + { + ReplyToCommand(client, "[SM] Usage: sm_couchunban <#userid|name>"); + return Plugin_Handled; + } + + char sArg[MAX_TARGET_LENGTH]; + GetCmdArg(1, sArg, sizeof(sArg)); + + int target = FindTarget(client, sArg, true, true); + if (target == -1) + return Plugin_Handled; + + if (!g_bBanned[target]) + { + ReplyToCommand(client, "[SM] %N is not couch-banned.", target); + return Plugin_Handled; + } + + DoUnban(client, target); + return Plugin_Handled; +} + +bool RequireCore(int client) +{ + if (g_bCoreLoaded) + return true; + + ReplyToCommand(client, "[SM] The Couch core plugin is not loaded."); + return false; +} + +// --------------------------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------------------------- + +void DoBan(int client, int target) +{ + g_bBanned[target] = true; + RememberAccount(target); + Couch_SetClientBlocked(target, true); + + // Announced to everyone so players know why the couch stopped reacting to someone. + PrintToChatAll("\x01[SM] \x04%N\x01 couch-banned \x04%N\x01 until the end of the map.", client, target); + LogAction(client, target, "\"%L\" couch-banned \"%L\" until the end of the map.", client, target); +} + +void DoUnban(int client, int target) +{ + g_bBanned[target] = false; + ForgetAccount(target); + Couch_SetClientBlocked(target, false); + + PrintToChatAll("\x01[SM] \x04%N\x01 removed couch-ban from \x04%N\x01.", client, target); + LogAction(client, target, "\"%L\" removed couch-ban from \"%L\".", client, target); +} + +// An unauthenticated client has no account id, so their ban ends with the session. +void RememberAccount(int client) +{ + int account = GetSteamAccountID(client); + if (account != 0 && g_alBannedAccounts.FindValue(account) == -1) + g_alBannedAccounts.Push(account); +} + +void ForgetAccount(int client) +{ + int account = GetSteamAccountID(client); + if (account == 0) + return; + + int index = g_alBannedAccounts.FindValue(account); + if (index != -1) + g_alBannedAccounts.Erase(index); +} diff --git a/Couch/scripting/CouchHud.sp b/Couch/scripting/CouchHud.sp new file mode 100644 index 0000000..b7e0a77 --- /dev/null +++ b/Couch/scripting/CouchHud.sp @@ -0,0 +1,316 @@ +#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(); +} diff --git a/Couch/scripting/include/Couch.inc b/Couch/scripting/include/Couch.inc new file mode 100644 index 0000000..6996402 --- /dev/null +++ b/Couch/scripting/include/Couch.inc @@ -0,0 +1,144 @@ +#if defined _Couch_included + #endinput +#endif + +#define _Couch_included + +// The core defines COUCH_CORE; it creates the forwards and natives itself. +#if !defined COUCH_CORE + +/** + * Called after the couch has been created, for admin spawns and round-start respawns. + * + * @param couch Entity index of the new couch. + */ +forward void Couch_OnCouchSpawned(int couch); + +/** + * Called once the couch entity is gone, for whatever reason. Couch_IsPlaced() tells whether + * it will come back at the next round start. + */ +forward void Couch_OnCouchRemoved(); + +/** + * Called for every hit the couch accepts from a player. Shotguns report once per pellet. + * + * @param couch Entity index of the couch. + * @param attacker Client index of the player who dealt the damage. + * @param damage Damage dealt by this hit. + */ +forward void Couch_OnCouchDamaged(int couch, int attacker, float damage); + +/** + * Called when a hit from a blocked client is refused. Fires per pellet, so rate-limit anything you send. + * + * @param couch Entity index of the couch. + * @param client Client index of the blocked player. + */ +forward void Couch_OnClientBlocked(int couch, int client); + +/** + * @return Entity index of the couch, or -1 if there is none. + */ +native int Couch_GetCouch(); + +/** + * Whether an admin has placed a couch this map. While true the couch respawns at the placed + * spot every round start. + * + * @return True if a placement is remembered. + */ +native bool Couch_IsPlaced(); + +/** + * Spawns the couch where the client is aiming, replacing any existing couch. The spot becomes + * the respawn point. + * + * @param client Client whose crosshair places the couch. Must be in game. + * @return Entity index of the couch, or -1 on failure. + */ +native int Couch_Spawn(int client); + +/** + * Teleports the couch to where the client is aiming. The respawn point is left as it was. + * + * @param client Client whose crosshair places the couch. Must be in game. + * @return True on success, false if there is no couch. + */ +native bool Couch_Bring(int client); + +/** + * Removes the couch and forgets its placement. + * + * @return True if a couch or a placement was removed. + */ +native bool Couch_Remove(); + +/** + * Whether the colour cycle and light are on. Mirrors sm_couch_effect. + */ +native bool Couch_IsEffectEnabled(); + +/** + * Switches the colour cycle and light on or off. Writes sm_couch_effect. + */ +native void Couch_SetEffectEnabled(bool enabled); + +/** + * Whether only pistols and knives may move the couch. Mirrors sm_couch_pistol_only. + */ +native bool Couch_IsPistolOnly(); + +/** + * Restricts couch movement to pistols and knives, or lifts the restriction. Writes sm_couch_pistol_only. + */ +native void Couch_SetPistolOnly(bool enabled); + +/** + * Blocks or unblocks a client from the couch: their hits are refused and their body can't push + * it. The flag is per slot and cleared on disconnect and map start; the caller owns persistence. + * + * @param client Client to block or unblock. Must be in game. + * @param blocked True to block, false to lift the block. + */ +native void Couch_SetClientBlocked(int client, bool blocked); + +/** + * Whether the client is currently blocked from the couch. + * + * @param client Client index. + * @return True if blocked. + */ +native bool Couch_IsClientBlocked(int client); + + +public SharedPlugin __pl_Couch = +{ + name = "Couch", + file = "Couch.smx", + + #if defined REQUIRE_PLUGIN + required = 1 + #else + required = 0 + #endif +}; + +#if !defined REQUIRE_PLUGIN + public void __pl_Couch_SetNTVOptional() + { + MarkNativeAsOptional("Couch_GetCouch"); + MarkNativeAsOptional("Couch_IsPlaced"); + MarkNativeAsOptional("Couch_Spawn"); + MarkNativeAsOptional("Couch_Bring"); + MarkNativeAsOptional("Couch_Remove"); + MarkNativeAsOptional("Couch_IsEffectEnabled"); + MarkNativeAsOptional("Couch_SetEffectEnabled"); + MarkNativeAsOptional("Couch_IsPistolOnly"); + MarkNativeAsOptional("Couch_SetPistolOnly"); + MarkNativeAsOptional("Couch_SetClientBlocked"); + MarkNativeAsOptional("Couch_IsClientBlocked"); + } +#endif + +#endif // COUCH_CORE