diff --git a/Couch/scripting/Couch.sp b/Couch/scripting/Couch.sp index 6abec09..111ad54 100644 --- a/Couch/scripting/Couch.sp +++ b/Couch/scripting/Couch.sp @@ -8,6 +8,11 @@ #define COUCH_CORE #include +// Optional: without Zombie:Reloaded nobody counts as a zombie, so couch HP never changes. +#undef REQUIRE_PLUGIN +#include +#define REQUIRE_PLUGIN + #pragma semicolon 1 #pragma newdecls required @@ -28,8 +33,8 @@ #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 +// Effect timer interval, ticks per colour, ticks per light pulse. +#define COUCH_EFFECT_INTERVAL 0.1 #define COUCH_COLOUR_TICKS 4 #define COUCH_PULSE_TICKS 10 @@ -38,6 +43,17 @@ #define COUCH_LIGHT_RADIUS_MIN 150.0 #define COUCH_LIGHT_RADIUS_MAX 320.0 +// Beacon, after sm_beacon: two beam rings a second from just above the couch's origin. No sound. +#define COUCH_BEACON_INTERVAL 1.0 +#define COUCH_BEACON_HEIGHT 10.0 +#define COUCH_BEACON_SPRITE_BEAM "sprites/laser.vmt" +#define COUCH_BEACON_SPRITE_HALO "sprites/halo01.vmt" + +// Outer ring is sm_beacon's grey; the inner ring is the couch's own orange, or the effect's +// current colour while the effect is on. +static const int g_iBeaconOuterColour[4] = { 128, 128, 128, 255 }; +static const int g_iBeaconInnerColour[4] = { 255, 144, 32, 255 }; + static const int g_iCouchColours[][3] = { { 255, 48, 48 }, // red @@ -49,6 +65,39 @@ static const int g_iCouchColours[][3] = { 255, 80, 224 }, // magenta }; +// Couch HP: regen is checked this often while the couch is below full. +#define COUCH_REGEN_INTERVAL 1.0 + +// Stock sounds, so they only need precaching. A zombie hit plays one of the first set at the couch; +// the break plays one of each of the other two, the explosion for everyone regardless of distance. +static const char g_sHitSounds[][] = +{ + "physics/wood/wood_furniture_break1.wav", + "physics/wood/wood_furniture_break2.wav", + "physics/wood/wood_box_break1.wav", + "physics/wood/wood_box_break2.wav", + "physics/wood/wood_plank_break1.wav", + "physics/wood/wood_plank_break2.wav", + "physics/wood/wood_plank_break3.wav", + "physics/wood/wood_plank_break4.wav", +}; + +static const char g_sBreakSounds[][] = +{ + "physics/wood/wood_crate_break1.wav", + "physics/wood/wood_crate_break2.wav", + "physics/wood/wood_crate_break3.wav", + "physics/wood/wood_crate_break4.wav", + "physics/wood/wood_crate_break5.wav", +}; + +static const char g_sExplodeSounds[][] = +{ + "weapons/hegrenade/explode3.wav", + "weapons/hegrenade/explode4.wav", + "weapons/hegrenade/explode5.wav", +}; + // Allowed to move the couch in pistol-only mode, along with the knife. static const char g_sPistols[][] = { @@ -66,6 +115,8 @@ ConVar g_cvPistolOnly; ConVar g_cvAllowUse; ConVar g_cvEffect; ConVar g_cvEffectIntensity; +ConVar g_cvBeacon; +ConVar g_cvBeaconRadius; ConVar g_cvIgnoreClip; ConVar g_cvCollisionGroup; @@ -74,11 +125,26 @@ ConVar g_cvCollisionGroup; ConVar g_cvMoveTier; ConVar g_cvButtonTier; +// Couch HP. Only zombie hits lower it; it is kept here rather than in the entity's health so the +// engine never breaks the prop itself. +ConVar g_cvHp; +ConVar g_cvHpMax; +ConVar g_cvHpRegenDelay; +ConVar g_cvHpRegen; + GlobalForward g_fwdOnCouchSpawned; GlobalForward g_fwdOnCouchRemoved; GlobalForward g_fwdOnCouchDamaged; GlobalForward g_fwdOnCouchInteraction; GlobalForward g_fwdOnClientBlocked; +GlobalForward g_fwdOnCouchHpChanged; +GlobalForward g_fwdOnCouchBroken; + +int g_iHp; +float g_fLastZombieHit; // GetGameTime() of the last hit that lowered the HP; regen waits on it. +Handle g_hRegenTimer; +bool g_bBroken; // Set by the breaking hit so the rest of its pellets, and anything else until the next spawn, is ignored. +bool g_bZRLoaded; int g_iCouchRef = INVALID_ENT_REFERENCE; int g_iLightRef = INVALID_ENT_REFERENCE; @@ -88,8 +154,19 @@ bool g_bPlaced; float g_vecPlaceOrigin[3]; float g_vecPlaceAngles[3]; +Handle g_hEffectTimer; +int g_iEffectTick; + +// Colour the effect last painted the couch; the beacon's inner ring follows it. +int g_iEffectColour[4] = { 255, 255, 255, 255 }; + Handle g_hBeaconTimer; -int g_iBeaconTick; +int g_iBeaconBeamSprite = -1; +int g_iBeaconHaloSprite = -1; + +// Accepted hits this round, announced at round end. Shotguns count once per pellet. +int g_iRoundHits; +float g_fRoundDamage; Handle g_hForceVPhysicsCollide; @@ -127,12 +204,20 @@ public APLRes AskPluginLoad2(Handle myself, bool late, char[] error, int err_max CreateNative("Couch_Remove", Native_Remove); CreateNative("Couch_IsEffectEnabled", Native_IsEffectEnabled); CreateNative("Couch_SetEffectEnabled", Native_SetEffectEnabled); + CreateNative("Couch_IsBeaconEnabled", Native_IsBeaconEnabled); + CreateNative("Couch_SetBeaconEnabled", Native_SetBeaconEnabled); CreateNative("Couch_IsPistolOnly", Native_IsPistolOnly); CreateNative("Couch_SetPistolOnly", Native_SetPistolOnly); CreateNative("Couch_IsUseAllowed", Native_IsUseAllowed); CreateNative("Couch_SetUseAllowed", Native_SetUseAllowed); CreateNative("Couch_SetClientBlocked", Native_SetClientBlocked); CreateNative("Couch_IsClientBlocked", Native_IsClientBlocked); + CreateNative("Couch_IsHpEnabled", Native_IsHpEnabled); + CreateNative("Couch_SetHpEnabled", Native_SetHpEnabled); + CreateNative("Couch_GetHp", Native_GetHp); + CreateNative("Couch_SetHp", Native_SetHp); + CreateNative("Couch_GetMaxHp", Native_GetMaxHp); + CreateNative("Couch_SetMaxHp", Native_SetMaxHp); RegPluginLibrary("Couch"); MarkNativeAsOptional("GetPlayerTier_native"); @@ -147,6 +232,8 @@ public void OnPluginStart() g_fwdOnCouchDamaged = new GlobalForward("Couch_OnCouchDamaged", ET_Ignore, Param_Cell, Param_Cell, Param_Float); g_fwdOnCouchInteraction = new GlobalForward("Couch_OnCouchInteraction", ET_Ignore, Param_Cell, Param_Cell, Param_Cell, Param_String); g_fwdOnClientBlocked = new GlobalForward("Couch_OnClientBlocked", ET_Ignore, Param_Cell, Param_Cell); + g_fwdOnCouchHpChanged = new GlobalForward("Couch_OnCouchHpChanged", ET_Ignore, Param_Cell, Param_Cell, Param_Cell); + g_fwdOnCouchBroken = new GlobalForward("Couch_OnCouchBroken", 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", "6.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); @@ -154,18 +241,28 @@ public void OnPluginStart() g_cvAllowUse = CreateConVar("sm_allow_couch_use", "1", "Players may push the couch by holding +use on it. 0 means it can only be moved by shooting it.", FCVAR_NOTIFY, true, 0.0, true, 1.0); g_cvEffect = CreateConVar("sm_couch_effect", "1", "Colour cycle and pulsing light on the couch.", FCVAR_NOTIFY, true, 0.0, true, 1.0); g_cvEffectIntensity = CreateConVar("sm_couch_effect_intensity", "2", "Brightness of the couch effect light. 0 is off, 2 is the default, 8 is very bright. Applies immediately.", FCVAR_NOTIFY, true, 0.0, true, 10.0); + g_cvBeacon = CreateConVar("sm_couch_beacon", "0", "Beacon on the couch, like sm_beacon: expanding rings every second.", FCVAR_NOTIFY, true, 0.0, true, 1.0); + g_cvBeaconRadius = CreateConVar("sm_couch_beacon_radius", "375", "Radius of the couch beacon's rings.", FCVAR_NOTIFY, true, 50.0, true, 1500.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_cvMoveTier = CreateConVar("sm_couch_move_tier", "0", "Minimum playtime tier needed to shoot or +use the couch.", FCVAR_NOTIFY, true, 0.0); g_cvButtonTier = CreateConVar("sm_couch_button_tier", "0", "Minimum playtime tier needed to press or shoot map buttons (func_button).", 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_cvHp = CreateConVar("sm_couch_hp", "1", "Couch HP: zombie hits wear the couch down and at 0 HP it breaks, killing every human.", FCVAR_NOTIFY, true, 0.0, true, 1.0); + g_cvHpMax = CreateConVar("sm_couch_hp_max", "4000", "Maximum couch HP. The couch spawns with this much every round.", FCVAR_NOTIFY, true, 1.0); + g_cvHpRegenDelay = CreateConVar("sm_couch_hp_regen_delay", "10","Seconds without a zombie hit before the couch starts regenerating HP.", FCVAR_NOTIFY, true, 0.0); + g_cvHpRegen = CreateConVar("sm_couch_hp_regen", "25", "HP the couch regenerates per second once the regen delay has passed.", FCVAR_NOTIFY, true, 0.0); g_cvModel.AddChangeHook(OnModelChanged); g_cvEffect.AddChangeHook(OnEffectChanged); g_cvEffectIntensity.AddChangeHook(OnEffectIntensityChanged); + g_cvBeacon.AddChangeHook(OnBeaconChanged); + g_cvHp.AddChangeHook(OnHpEnabledChanged); + g_cvHpMax.AddChangeHook(OnHpMaxChanged); AutoExecConfig(true, "Couch"); HookEvent("round_start", Event_RoundStart, EventHookMode_Post); + HookEvent("round_end", Event_RoundEnd, EventHookMode_Post); g_cvTurboPhysics = FindConVar("sv_turbophysics"); @@ -196,16 +293,42 @@ public void OnPluginEnd() KillCouch(); } +public void OnAllPluginsLoaded() +{ + g_bZRLoaded = LibraryExists("zombiereloaded"); +} + +public void OnLibraryAdded(const char[] name) +{ + if (StrEqual(name, "zombiereloaded")) + g_bZRLoaded = true; +} + +public void OnLibraryRemoved(const char[] name) +{ + if (StrEqual(name, "zombiereloaded")) + g_bZRLoaded = false; +} + public void OnMapStart() { PrecacheCouchModel(); + PrecacheBeacon(); + PrecacheHpSounds(); // Entities and no-mapchange timers went with the map. g_iCouchRef = INVALID_ENT_REFERENCE; g_iLightRef = INVALID_ENT_REFERENCE; g_bPlaced = false; + g_hEffectTimer = null; + g_iEffectTick = 0; g_hBeaconTimer = null; - g_iBeaconTick = 0; + g_iRoundHits = 0; + g_fRoundDamage = 0.0; + g_hRegenTimer = null; + g_iHp = 0; + g_fLastZombieHit = 0.0; + g_bBroken = false; ResetBlockedClients(); } @@ -228,7 +351,9 @@ public void OnEntityDestroyed(int entity) // until the next round start. g_iCouchRef = INVALID_ENT_REFERENCE; KillLight(); + StopEffect(); StopBeacon(); + StopRegen(); Call_StartForward(g_fwdOnCouchRemoved); Call_Finish(); @@ -250,6 +375,16 @@ public void OnClientDisconnect(int client) public void Event_RoundStart(Event event, const char[] name, bool dontBroadcast) { + g_iRoundHits = 0; + g_fRoundDamage = 0.0; + + // A couch that survived the restart starts the round at full HP too. + if (GetCouchIndex() != -1) + { + ResetHp(); + UpdateRegen(); + } + if (!g_bPlaced || GetCouchIndex() != -1) return; @@ -260,13 +395,33 @@ public void Event_RoundStart(Event event, const char[] name, bool dontBroadcast) CreateTimer(COUCH_ANNOUNCE_DELAY, Timer_AnnounceCouchRound, _, TIMER_FLAG_NO_MAPCHANGE); } +// Only for rounds that had a couch: placed, or spawned mid-round and shot. +public void Event_RoundEnd(Event event, const char[] name, bool dontBroadcast) +{ + if (!g_bPlaced && g_iRoundHits == 0) + return; + + if (g_iRoundHits == 0) + { + PrintToChatAll(PREFIX ... "\x01The couch took \x04no damage\x01 this round."); + return; + } + + char sDamage[16]; + char sHits[16]; + FormatThousands(RoundToNearest(g_fRoundDamage), sDamage, sizeof(sDamage)); + FormatThousands(g_iRoundHits, sHits, sizeof(sHits)); + + PrintToChatAll(PREFIX ... "\x01The couch took \x04%s damage\x01 from \x04%s hit%s\x01 this round.", sDamage, sHits, (g_iRoundHits == 1) ? "" : "s"); +} + 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."); + PrintToChatAll(PREFIX ... "Couch round!\x01 Humans must bring the couch with them throughout the map or they fail the round."); return Plugin_Stop; } @@ -450,12 +605,14 @@ int SpawnCouchAt(const float vecOrigin[3], const float vecAngles[3]) // SDKHooks drops the hooks with the entity. SDKHook(couch, SDKHook_OnTakeDamage, OnCouchTakeDamage); - // Takes the beacon's colour modulation at full opacity. + // Takes the effect's colour modulation at full opacity. SetEntityRenderMode(couch, RENDER_NORMAL); g_iCouchRef = EntIndexToEntRef(couch); + ResetHp(); ApplyEffectState(); + ApplyBeaconState(); Call_StartForward(g_fwdOnCouchSpawned); Call_PushCell(couch); @@ -471,7 +628,9 @@ bool KillCouch() // Cleared first so OnEntityDestroyed doesn't run the external-removal path too. g_iCouchRef = INVALID_ENT_REFERENCE; + StopEffect(); StopBeacon(); + StopRegen(); KillLight(); if (couch == -1) @@ -494,11 +653,33 @@ public Action OnCouchTakeDamage(int victim, int &attacker, int &inflictor, float { bool bFromClient = (attacker >= 1 && attacker <= MaxClients && IsClientInGame(attacker)); - if (bFromClient && IsClientRefused(victim, attacker)) - return Plugin_Handled; + // With couch HP on, a zombie's hit always costs HP: couch bans, the tier and pistol-only mode don't + // stop it. A banned zombie's hit still counts and is reported, but is refused at the end so it + // doesn't move the couch. + bool bRefusePush = false; - if (bFromClient && g_cvPistolOnly.BoolValue && !IsPistolOrKnifeHit(attacker, weapon, damagetype)) - return Plugin_Handled; + if (bFromClient && g_cvHp.BoolValue && IsZombie(attacker)) + { + if (g_bBroken) + return Plugin_Handled; + + if (ApplyZombieHit(victim, attacker, damage)) + return Plugin_Handled; + + bRefusePush = g_bBlocked[attacker]; + } + else + { + if (bFromClient && IsClientRefused(victim, attacker)) + return Plugin_Handled; + + if (bFromClient && g_cvPistolOnly.BoolValue && !IsPistolOrKnifeHit(attacker, weapon, damagetype)) + return Plugin_Handled; + } + + // Counted only once the hit is accepted, so refused shots don't show in the round total. + g_iRoundHits++; + g_fRoundDamage += damage; Action result = Plugin_Continue; @@ -520,7 +701,210 @@ public Action OnCouchTakeDamage(int victim, int &attacker, int &inflictor, float ReportHit(victim, attacker, inflictor, weapon, damagetype); } - return result; + return bRefusePush ? Plugin_Handled : result; +} + +// --------------------------------------------------------------------------------------------- +// Couch HP +// --------------------------------------------------------------------------------------------- + +bool IsZombie(int client) +{ + return g_bZRLoaded && ZR_IsClientZombie(client); +} + +void PrecacheHpSounds() +{ + for (int i = 0; i < sizeof(g_sHitSounds); i++) + PrecacheSound(g_sHitSounds[i], true); + + for (int i = 0; i < sizeof(g_sBreakSounds); i++) + PrecacheSound(g_sBreakSounds[i], true); + + for (int i = 0; i < sizeof(g_sExplodeSounds); i++) + PrecacheSound(g_sExplodeSounds[i], true); +} + +public void OnHpEnabledChanged(ConVar convar, const char[] oldValue, const char[] newValue) +{ + if (convar.BoolValue) + { + ResetHp(); + UpdateRegen(); + return; + } + + // Announced so the HUD drops its HP line. + StopRegen(); + FireHpChanged(); +} + +// A full couch stays full; anything else is clamped to the new maximum. +public void OnHpMaxChanged(ConVar convar, const char[] oldValue, const char[] newValue) +{ + int iOldMax = StringToInt(oldValue); + int iNewMax = convar.IntValue; + + if (g_iHp >= iOldMax || g_iHp > iNewMax) + g_iHp = iNewMax; + + FireHpChanged(); + UpdateRegen(); +} + +// Full HP; on spawn, round start and when the feature is switched on. +void ResetHp() +{ + g_iHp = g_cvHpMax.IntValue; + g_bBroken = false; + g_fLastZombieHit = 0.0; + FireHpChanged(); +} + +// Clamped to 0..max. Doesn't touch the regen timer; callers decide that. +void SetHp(int hp) +{ + int iMax = g_cvHpMax.IntValue; + if (hp < 0) + hp = 0; + else if (hp > iMax) + hp = iMax; + + if (hp == g_iHp) + return; + + g_iHp = hp; + FireHpChanged(); +} + +void FireHpChanged() +{ + Call_StartForward(g_fwdOnCouchHpChanged); + Call_PushCell(GetCouchIndex()); + Call_PushCell(g_iHp); + Call_PushCell(g_cvHpMax.IntValue); + Call_Finish(); +} + +// Brings the regen timer in line with the state: it runs only while a live couch is below full HP. +// Not called from inside Timer_Regen; that returns Plugin_Stop instead. +void UpdateRegen() +{ + if (!g_cvHp.BoolValue || g_bBroken || GetCouchIndex() == -1 || g_iHp >= g_cvHpMax.IntValue) + { + StopRegen(); + return; + } + + StartRegen(); +} + +void StartRegen() +{ + if (g_hRegenTimer == null) + g_hRegenTimer = CreateTimer(COUCH_REGEN_INTERVAL, Timer_Regen, _, TIMER_REPEAT | TIMER_FLAG_NO_MAPCHANGE); +} + +void StopRegen() +{ + delete g_hRegenTimer; +} + +public Action Timer_Regen(Handle timer) +{ + int iMax = g_cvHpMax.IntValue; + + if (!g_cvHp.BoolValue || g_bBroken || GetCouchIndex() == -1 || g_iHp >= iMax) + { + g_hRegenTimer = null; + return Plugin_Stop; + } + + // Waiting out the delay since the last hit; a new hit pushes it back. + if (GetGameTime() - g_fLastZombieHit < g_cvHpRegenDelay.FloatValue) + return Plugin_Continue; + + SetHp(g_iHp + g_cvHpRegen.IntValue); + + if (g_iHp >= iMax) + { + g_hRegenTimer = null; + return Plugin_Stop; + } + + return Plugin_Continue; +} + +// Takes the hit off the couch's HP and plays a break sound at it. Returns true if this hit broke the couch. +bool ApplyZombieHit(int couch, int attacker, float damage) +{ + // Every hit costs at least 1 HP, whatever the knife does to props. + int iDamage = RoundToNearest(damage); + if (iDamage < 1) + iDamage = 1; + + g_fLastZombieHit = GetGameTime(); + SetHp(g_iHp - iDamage); + + EmitSoundToAll(g_sHitSounds[GetRandomInt(0, sizeof(g_sHitSounds) - 1)], couch, SNDCHAN_STATIC, SNDLEVEL_NORMAL); + + if (g_iHp > 0) + { + UpdateRegen(); + return false; + } + + BreakCouch(couch, attacker); + return true; +} + +// 0 HP: the couch breaks loudly, every human dies and the couch is removed; it comes back at full HP +// at the next round start if it was placed. attacker is 0 for an admin set. +void BreakCouch(int couch, int attacker) +{ + g_bBroken = true; + StopRegen(); + + // The explosion is heard by everyone at full volume; the splintering is placed at the couch. + EmitSoundToAll(g_sExplodeSounds[GetRandomInt(0, sizeof(g_sExplodeSounds) - 1)], SOUND_FROM_PLAYER, SNDCHAN_STATIC, SNDLEVEL_NONE); + EmitSoundToAll(g_sBreakSounds[GetRandomInt(0, sizeof(g_sBreakSounds) - 1)], couch, SNDCHAN_STATIC, SNDLEVEL_SCREAMING); + + if (attacker >= 1 && attacker <= MaxClients && IsClientInGame(attacker)) + PrintToChatAll(PREFIX ... "%N\x01 broke the couch! Game over!", attacker); + else + PrintToChatAll(PREFIX ... "The couch\x01 broke! Game over!"); + + // While the entity still exists, so listeners can read its position. + Call_StartForward(g_fwdOnCouchBroken); + Call_PushCell(couch); + Call_PushCell(attacker); + Call_Finish(); + + if (g_bZRLoaded) + { + for (int i = 1; i <= MaxClients; i++) + { + if (IsClientInGame(i) && IsPlayerAlive(i) && ZR_IsClientHuman(i)) + KillHuman(i); + } + } + + // Last: fires Couch_OnCouchRemoved. The Kill input is deferred, so this is safe from inside the + // couch's own damage hook; g_bBroken swallows the rest of that hit's pellets. + KillCouch(); +} + +// Kills a human when the couch breaks. Lethal world damage is an ordinary death: it runs the damage +// path, fires player_death and leaves a ragdoll, where CommitSuicide can be refused or skip the +// ragdoll. The damage is well over health plus kevlar so armour cannot save them; suicide is the +// fallback if another plugin swallowed the damage. +void KillHuman(int client) +{ + float fDamage = float(GetClientHealth(client) + GetClientArmor(client)) * 2.0 + 1000.0; + SDKHooks_TakeDamage(client, 0, 0, fDamage, DMG_GENERIC); + + if (IsPlayerAlive(client)) + ForcePlayerSuicide(client); } // Reach of the engine's +use push. @@ -934,14 +1318,14 @@ void ApplyLightIntensity() AcceptEntityInput(light, "brightness"); } -// Brings the light and beacon in line with sm_couch_effect. Safe to call at any time. +// Brings the light and colour cycle in line with sm_couch_effect. Safe to call at any time. void ApplyEffectState() { int couch = GetCouchIndex(); if (couch == -1 || !g_cvEffect.BoolValue) { - StopBeacon(); + StopEffect(); KillLight(); if (couch != -1) @@ -953,7 +1337,7 @@ void ApplyEffectState() if (GetLightIndex() == -1) g_iLightRef = SpawnCouchLight(couch); - StartBeacon(); + StartEffect(); } // light_dynamic parented to the couch. Returns INVALID_ENT_REFERENCE on failure. @@ -1002,6 +1386,84 @@ void KillLight() AcceptEntityInput(light, "Kill"); } +void StartEffect() +{ + if (g_hEffectTimer == null) + g_hEffectTimer = CreateTimer(COUCH_EFFECT_INTERVAL, Timer_Effect, _, TIMER_REPEAT | TIMER_FLAG_NO_MAPCHANGE); +} + +// Not called from inside Timer_Effect; that returns Plugin_Stop instead. +void StopEffect() +{ + delete g_hEffectTimer; +} + +public Action Timer_Effect(Handle timer) +{ + int couch = GetCouchIndex(); + if (couch == -1) + { + g_hEffectTimer = null; + return Plugin_Stop; + } + + g_iEffectTick++; + + int colour = (g_iEffectTick / 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); + + g_iEffectColour[0] = r; + g_iEffectColour[1] = g; + g_iEffectColour[2] = b; + + 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_iEffectTick % 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; +} + +// --------------------------------------------------------------------------------------------- +// Beacon +// --------------------------------------------------------------------------------------------- + +public void OnBeaconChanged(ConVar convar, const char[] oldValue, const char[] newValue) +{ + ApplyBeaconState(); +} + +// The sprites are stock CS:S content, the same ones sm_beacon uses. +void PrecacheBeacon() +{ + g_iBeaconBeamSprite = PrecacheModel(COUCH_BEACON_SPRITE_BEAM, true); + g_iBeaconHaloSprite = PrecacheModel(COUCH_BEACON_SPRITE_HALO, true); +} + +// Brings the beacon in line with sm_couch_beacon. Safe to call at any time. +void ApplyBeaconState() +{ + if (GetCouchIndex() == -1 || !g_cvBeacon.BoolValue) + { + StopBeacon(); + return; + } + + StartBeacon(); +} + void StartBeacon() { if (g_hBeaconTimer == null) @@ -1014,6 +1476,7 @@ void StopBeacon() delete g_hBeaconTimer; } +// Same rings as sm_beacon's Timer_Beacon, from the couch instead of a player, without its blip. public Action Timer_Beacon(Handle timer) { int couch = GetCouchIndex(); @@ -1023,27 +1486,26 @@ public Action Timer_Beacon(Handle timer) 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) + if (g_iBeaconBeamSprite <= 0 || g_iBeaconHaloSprite <= 0) return Plugin_Continue; - SetEntityRenderColor(light, r, g, b, 255); + float vecOrigin[3]; + GetEntPropVector(couch, Prop_Send, "m_vecOrigin", vecOrigin); + vecOrigin[2] += COUCH_BEACON_HEIGHT; - // 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; + int iInnerColour[4]; + if (g_cvEffect.BoolValue) + iInnerColour = g_iEffectColour; + else + iInnerColour = g_iBeaconInnerColour; - SetVariantFloat(COUCH_LIGHT_RADIUS_MIN + fPulse * (COUCH_LIGHT_RADIUS_MAX - COUCH_LIGHT_RADIUS_MIN)); - AcceptEntityInput(light, "distance"); + float fRadius = g_cvBeaconRadius.FloatValue; + + TE_SetupBeamRingPoint(vecOrigin, 10.0, fRadius, g_iBeaconBeamSprite, g_iBeaconHaloSprite, 0, 15, 0.5, 5.0, 0.0, g_iBeaconOuterColour, 10, 0); + TE_SendToAll(); + + TE_SetupBeamRingPoint(vecOrigin, 10.0, fRadius, g_iBeaconBeamSprite, g_iBeaconHaloSprite, 0, 10, 0.6, 10.0, 0.5, iInnerColour, 10, 0); + TE_SendToAll(); return Plugin_Continue; } @@ -1151,6 +1613,17 @@ public any Native_SetEffectEnabled(Handle plugin, int numParams) return 0; } +public any Native_IsBeaconEnabled(Handle plugin, int numParams) +{ + return g_cvBeacon.BoolValue; +} + +public any Native_SetBeaconEnabled(Handle plugin, int numParams) +{ + g_cvBeacon.SetBool(view_as(GetNativeCell(1))); + return 0; +} + public any Native_IsPistolOnly(Handle plugin, int numParams) { return g_cvPistolOnly.BoolValue; @@ -1191,3 +1664,49 @@ public any Native_IsClientBlocked(Handle plugin, int numParams) return g_bBlocked[client]; } + +public any Native_IsHpEnabled(Handle plugin, int numParams) +{ + return g_cvHp.BoolValue; +} + +public any Native_SetHpEnabled(Handle plugin, int numParams) +{ + g_cvHp.SetBool(view_as(GetNativeCell(1))); + return 0; +} + +public any Native_GetHp(Handle plugin, int numParams) +{ + return (GetCouchIndex() == -1) ? 0 : g_iHp; +} + +// 0 breaks the couch like a zombie hit would, which gives admins a way to test the break. +public any Native_SetHp(Handle plugin, int numParams) +{ + SetHp(GetNativeCell(1)); + + int couch = GetCouchIndex(); + if (g_iHp == 0 && g_cvHp.BoolValue && !g_bBroken && couch != -1) + BreakCouch(couch, 0); + else + UpdateRegen(); + + return 0; +} + +public any Native_GetMaxHp(Handle plugin, int numParams) +{ + return g_cvHpMax.IntValue; +} + +// The cvar's change hook keeps a full couch full and clamps the rest. +public any Native_SetMaxHp(Handle plugin, int numParams) +{ + int maxhp = GetNativeCell(1); + if (maxhp < 1) + maxhp = 1; + + g_cvHpMax.SetInt(maxhp); + return 0; +} diff --git a/Couch/scripting/CouchAdmin.sp b/Couch/scripting/CouchAdmin.sp index 2676877..c6ab64b 100644 --- a/Couch/scripting/CouchAdmin.sp +++ b/Couch/scripting/CouchAdmin.sp @@ -4,8 +4,6 @@ #pragma semicolon 1 #pragma newdecls required -#define PREFIX "\x01[SM] \x04" - // A player who keeps moving the couch the same way is reported at most once per this many seconds. #define LOG_REPEAT_INTERVAL 2.0 @@ -15,7 +13,8 @@ // So commands fail with a message instead of an unbound-native error while the core is unloaded. bool g_bCoreLoaded; -// Admins who asked to see couch interactions. Per slot, so it survives map changes. +// Who sees couch interactions in their console. On by default for anyone who can run sm_couchlog, +// off for everyone else. Per slot, so it survives map changes. bool g_bLogging[MAXPLAYERS + 1]; // Per player: what was last reported, and when the same thing may be reported again. @@ -39,8 +38,32 @@ public void OnPluginStart() RegAdminCmd("sm_bringcouch", Command_BringCouch, ADMFLAG_CHANGEMAP, "Brings the couch to where you are aiming"); RegAdminCmd("sm_gotocouch", Command_GotoCouch, ADMFLAG_CHANGEMAP, "Teleports you to the couch"); RegAdminCmd("sm_removecouch", Command_RemoveCouch, ADMFLAG_CHANGEMAP, "Removes the couch"); + RegAdminCmd("sm_couchbeacon", Command_Beacon, ADMFLAG_CHANGEMAP, "sm_couchbeacon [0|1] - beacon rings on the couch; no argument toggles"); RegAdminCmd("sm_couchpistolonly", Command_PistolOnly, ADMFLAG_CHANGEMAP, "sm_couchpistolonly [0|1] - only pistols and knives move the couch; no argument toggles"); RegAdminCmd("sm_couchlog", Command_Log, ADMFLAG_CHANGEMAP, "sm_couchlog [0|1] - show who moves the couch and how in your console; no argument toggles"); + RegAdminCmd("sm_couchhp", Command_Hp, ADMFLAG_CHANGEMAP, "sm_couchhp [hp] - sets the couch's HP; no argument shows it"); + RegAdminCmd("sm_couchmaxhp", Command_MaxHp, ADMFLAG_CHANGEMAP, "sm_couchmaxhp - sets the couch's maximum HP"); + + // Late load: admins are already in, so give them their default. + for (int client = 1; client <= MaxClients; client++) + { + if (IsClientInGame(client) && !IsFakeClient(client)) + g_bLogging[client] = IsLoggingAdmin(client); + } +} + +// Admin access is only known once the admin check has run, so the default is set here rather +// than on connect. +public void OnClientPostAdminCheck(int client) +{ + if (!IsFakeClient(client)) + g_bLogging[client] = IsLoggingAdmin(client); +} + +// Follows the command's access, so an admin override on sm_couchlog changes who gets the default. +bool IsLoggingAdmin(int client) +{ + return CheckCommandAccess(client, "sm_couchlog", ADMFLAG_CHANGEMAP); } // Game time restarts with the map. @@ -123,6 +146,19 @@ public Action Command_RemoveCouch(int client, int args) return Plugin_Handled; } +public Action Command_Beacon(int client, int args) +{ + if (!RequireCore(client)) + return Plugin_Handled; + + bool bEnable = !Couch_IsBeaconEnabled(); + if (args >= 1) + bEnable = (GetCmdArgInt(1) != 0); + + DoSetBeacon(client, bEnable); + return Plugin_Handled; +} + public Action Command_PistolOnly(int client, int args) { if (!RequireCore(client)) @@ -136,6 +172,52 @@ public Action Command_PistolOnly(int client, int args) return Plugin_Handled; } +public Action Command_Hp(int client, int args) +{ + if (!RequireCore(client)) + return Plugin_Handled; + + if (Couch_GetCouch() == -1) + { + ReplyToCommand(client, PREFIX_PLAIN ... " There is no couch. Use sm_spawncouch first."); + return Plugin_Handled; + } + + if (args < 1) + { + char sHp[16], sMax[16]; + FormatThousands(Couch_GetHp(), sHp, sizeof(sHp)); + FormatThousands(Couch_GetMaxHp(), sMax, sizeof(sMax)); + ReplyToCommand(client, PREFIX_PLAIN ... " Couch HP: %s/%s (couch HP is %s).", sHp, sMax, Couch_IsHpEnabled() ? "on" : "off"); + return Plugin_Handled; + } + + DoSetHp(client, GetCmdArgInt(1)); + return Plugin_Handled; +} + +public Action Command_MaxHp(int client, int args) +{ + if (!RequireCore(client)) + return Plugin_Handled; + + if (args < 1) + { + ReplyToCommand(client, PREFIX_PLAIN ... " Usage: sm_couchmaxhp "); + return Plugin_Handled; + } + + int hp = GetCmdArgInt(1); + if (hp < 1) + { + ReplyToCommand(client, PREFIX_PLAIN ... " The maximum HP must be at least 1."); + return Plugin_Handled; + } + + DoSetMaxHp(client, hp); + return Plugin_Handled; +} + public Action Command_Log(int client, int args) { if (!RequireInGame(client)) @@ -154,7 +236,7 @@ bool RequireInGame(int client) if (client >= 1 && IsClientInGame(client)) return true; - ReplyToCommand(client, "[SM] This command can only be used in-game."); + ReplyToCommand(client, PREFIX_PLAIN ... " This command can only be used in-game."); return false; } @@ -163,7 +245,7 @@ bool RequireCore(int client) if (g_bCoreLoaded) return true; - ReplyToCommand(client, "[SM] The Couch core plugin is not loaded."); + ReplyToCommand(client, PREFIX_PLAIN ... " The Couch core plugin is not loaded."); return false; } @@ -175,7 +257,7 @@ void DoSpawn(int client) { if (Couch_Spawn(client) == -1) { - ReplyToCommand(client, "[SM] Could not create the couch."); + ReplyToCommand(client, PREFIX_PLAIN ... " Could not create the couch."); return; } @@ -187,7 +269,7 @@ void DoBring(int client) { if (!Couch_Bring(client)) { - ReplyToCommand(client, "[SM] There is no couch to bring. Use sm_spawncouch first."); + ReplyToCommand(client, PREFIX_PLAIN ... " There is no couch to bring. Use sm_spawncouch first."); return; } @@ -199,13 +281,13 @@ void DoGoto(int client) { if (!IsPlayerAlive(client)) { - ReplyToCommand(client, "[SM] You must be alive to go to the couch."); + ReplyToCommand(client, PREFIX_PLAIN ... " You must be alive to go to the couch."); return; } if (!Couch_Goto(client)) { - ReplyToCommand(client, "[SM] There is no couch to go to. Use sm_spawncouch first."); + ReplyToCommand(client, PREFIX_PLAIN ... " There is no couch to go to. Use sm_spawncouch first."); return; } @@ -217,7 +299,7 @@ void DoRemove(int client) { if (!Couch_Remove()) { - ReplyToCommand(client, "[SM] There is no couch to remove."); + ReplyToCommand(client, PREFIX_PLAIN ... " There is no couch to remove."); return; } @@ -234,6 +316,14 @@ void DoToggleEffect(int client) LogAction(client, -1, "\"%L\" turned the couch effect %s.", client, bEnable ? "on" : "off"); } +void DoSetBeacon(int client, bool bEnable) +{ + Couch_SetBeaconEnabled(bEnable); + + ShowActivity2(client, PREFIX, "\x01Turned the couch beacon \x04%s\x01.", bEnable ? "on" : "off"); + LogAction(client, -1, "\"%L\" turned the couch beacon %s.", client, bEnable ? "on" : "off"); +} + // Announced to everyone so players know why their rifle stopped moving the couch. void DoSetPistolOnly(int client, bool bEnable) { @@ -257,6 +347,45 @@ void DoToggleUse(int client) LogAction(client, -1, "\"%L\" %s couch use.", client, bAllow ? "allowed" : "disallowed"); } +void DoSetHpEnabled(int client, bool bEnable) +{ + Couch_SetHpEnabled(bEnable); + + if (bEnable) + ShowActivity2(client, PREFIX, "\x01Turned couch HP \x04on\x01, zombies can break the couch now."); + else + ShowActivity2(client, PREFIX, "\x01Turned couch HP \x04off\x01."); + + LogAction(client, -1, "\"%L\" turned couch HP %s.", client, bEnable ? "on" : "off"); +} + +// Read back afterwards so the announcement shows the clamped value. 0 breaks the couch. +void DoSetHp(int client, int hp) +{ + Couch_SetHp(hp); + hp = Couch_GetHp(); + + char sHp[16], sMax[16]; + FormatThousands(hp, sHp, sizeof(sHp)); + FormatThousands(Couch_GetMaxHp(), sMax, sizeof(sMax)); + + ShowActivity2(client, PREFIX, "\x01Set the couch's \x04HP\x01 to \x04%s/%s\x01.", sHp, sMax); + LogAction(client, -1, "\"%L\" set the couch's HP to %d.", client, hp); +} + +void DoSetMaxHp(int client, int hp) +{ + Couch_SetMaxHp(hp); + hp = Couch_GetMaxHp(); + + char sHp[16], sMax[16]; + FormatThousands(Couch_GetHp(), sHp, sizeof(sHp)); + FormatThousands(hp, sMax, sizeof(sMax)); + + ShowActivity2(client, PREFIX, "\x01Set the couch's \x04maximum HP\x01 to \x04%s\x01, it is now at \x04%s/%s\x01.", sMax, sHp, sMax); + LogAction(client, -1, "\"%L\" set the couch's maximum HP to %d.", client, hp); +} + // The tier cvars belong to the core plugin, so they are looked up by name. sName is "move" or "button". void DoChangeTier(int client, const char[] sName, int iDelta) { @@ -308,7 +437,8 @@ void DoSetPushScale(int client, float fScale) LogAction(client, -1, "\"%L\" set the couch push scale to %.2f.", client, fScale); } -// Only affects what this admin sees, so it is neither announced nor logged. +// Only affects what this admin sees, so it is neither announced nor logged. The choice lasts +// until they disconnect; on their next connect the log is on again. void DoSetLogging(int client, bool bEnable) { g_bLogging[client] = bEnable; @@ -316,7 +446,7 @@ void DoSetLogging(int client, bool bEnable) if (bEnable) PrintToChat(client, PREFIX ... "\x01Couch log \x04on\x01, see your console for who moves the couch and how."); else - PrintToChat(client, PREFIX ... "\x01Couch log \x04off\x01."); + PrintToChat(client, PREFIX ... "\x01Couch log \x04off\x01 until you reconnect."); } // --------------------------------------------------------------------------------------------- @@ -328,7 +458,7 @@ public void Couch_OnCouchInteraction(int couch, int client, CouchMethod method, // Grenades are rare and knock the couch far, so everyone is told. One blast is one report, so no // rate limit is needed. The userid is there so an admin can put it straight into sm_couchban. if (method == CouchMethod_Grenade) - PrintToChatAll("\x01[Couch] \x04%N\x01 (#%d) hit the couch with a \x04grenade", client, GetClientUserId(client)); + PrintToChatAll(PREFIX ... "%N\x01 (#%d) hit the couch with a \x04grenade", client, GetClientUserId(client)); if (!AnyoneLogging()) return; @@ -366,7 +496,7 @@ public void Couch_OnCouchInteraction(int couch, int client, CouchMethod method, if (!g_bLogging[i] || !IsClientInGame(i)) continue; - PrintToConsole(i, "[Couch] %N (#%d) %s", client, userid, sHow); + PrintToConsole(i, PREFIX_PLAIN ... " %N (#%d) %s", client, userid, sHow); } } @@ -407,18 +537,19 @@ void ShowCouchMenu(int client) 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); FormatEx(sItem, sizeof(sItem), "Allow USE: %s", Couch_IsUseAllowed() ? "ON" : "OFF"); menu.AddItem("use", sItem); + FormatEx(sItem, sizeof(sItem), "Couch HP: %s", Couch_IsHpEnabled() ? "ON" : "OFF"); + menu.AddItem("hp", sItem); + FormatEx(sItem, sizeof(sItem), "Couch logs (for me): %s", g_bLogging[client] ? "ON" : "OFF"); menu.AddItem("log", sItem); + menu.AddItem("effects", "Effects"); menu.AddItem("tiers", "Tier requirements"); menu.AddItem("pushscale", "Push scale"); @@ -451,14 +582,19 @@ public int MenuHandler_Couch(Menu menu, MenuAction action, int param1, int param DoGoto(client); else if (StrEqual(sInfo, "remove")) DoRemove(client); - else if (StrEqual(sInfo, "effect")) - DoToggleEffect(client); else if (StrEqual(sInfo, "pistol")) DoSetPistolOnly(client, !Couch_IsPistolOnly()); else if (StrEqual(sInfo, "use")) DoToggleUse(client); + else if (StrEqual(sInfo, "hp")) + DoSetHpEnabled(client, !Couch_IsHpEnabled()); else if (StrEqual(sInfo, "log")) DoSetLogging(client, !g_bLogging[client]); + else if (StrEqual(sInfo, "effects")) + { + ShowEffectsMenu(client); + return 0; + } else if (StrEqual(sInfo, "tiers")) { ShowTierMenu(client); @@ -482,6 +618,69 @@ public int MenuHandler_Couch(Menu menu, MenuAction action, int param1, int param return 0; } +// Rebuilt on every display so the labels always reflect the current state. +void ShowEffectsMenu(int client) +{ + Menu menu = new Menu(MenuHandler_Effects); + menu.SetTitle("Couch - Effects"); + + char sItem[64]; + FormatEx(sItem, sizeof(sItem), "Effect: %s", Couch_IsEffectEnabled() ? "ON" : "OFF"); + menu.AddItem("effect", sItem); + + FormatEx(sItem, sizeof(sItem), "Beacon: %s", Couch_IsBeaconEnabled() ? "ON" : "OFF"); + menu.AddItem("beacon", sItem); + + menu.ExitBackButton = true; + menu.ExitButton = true; + menu.Display(client, MENU_TIME_FOREVER); +} + +public int MenuHandler_Effects(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, "effect")) + DoToggleEffect(client); + else if (StrEqual(sInfo, "beacon")) + DoSetBeacon(client, !Couch_IsBeaconEnabled()); + + ShowEffectsMenu(client); + } + + case MenuAction_Cancel: + { + if (param2 == MenuCancel_ExitBack) + { + if (g_bCoreLoaded) + ShowCouchMenu(param1); + else + RequireCore(param1); + } + } + + case MenuAction_End: + { + delete menu; + } + } + + return 0; +} + void ShowTierMenu(int client) { Menu menu = new Menu(MenuHandler_Tier); diff --git a/Couch/scripting/CouchBan.sp b/Couch/scripting/CouchBan.sp index 79db6a9..8135968 100644 --- a/Couch/scripting/CouchBan.sp +++ b/Couch/scripting/CouchBan.sp @@ -4,8 +4,6 @@ #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; @@ -30,13 +28,13 @@ public Plugin myinfo = public void OnPluginStart() { - LoadTranslations("common.phrases"); // FindTarget's replies. + LoadTranslations("common.phrases"); // Target errors and multi-target names ("all players"). 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_couchban", Command_CouchBan, ADMFLAG_BAN, "sm_couchban <#userid|name|@filter> - bans players 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_couchunban", Command_CouchUnban, ADMFLAG_BAN, "sm_couchunban <#userid|name|@filter> - lifts couch bans"); RegAdminCmd("sm_cunban", Command_CouchUnban, ADMFLAG_BAN, "Alias of sm_couchunban"); } @@ -143,24 +141,11 @@ public Action Command_CouchBan(int client, int args) if (args < 1) { - ReplyToCommand(client, "[SM] Usage: sm_couchban <#userid|name>"); + ReplyToCommand(client, PREFIX_PLAIN ... " Usage: sm_couchban <#userid|name|@filter>"); 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); + ProcessBanCommand(client, true); return Plugin_Handled; } @@ -171,25 +156,83 @@ public Action Command_CouchUnban(int client, int args) if (args < 1) { - ReplyToCommand(client, "[SM] Usage: sm_couchunban <#userid|name>"); + ReplyToCommand(client, PREFIX_PLAIN ... " Usage: sm_couchunban <#userid|name|@filter>"); return Plugin_Handled; } + ProcessBanCommand(client, false); + return Plugin_Handled; +} + +// Standard SourceMod targeting: a name, #userid or @filter (@all, @t, @zombies ...). Players already in +// the wanted state are skipped. One chat line covers the whole command; each ban is still logged. +void ProcessBanCommand(int client, bool bBan) +{ char sArg[MAX_TARGET_LENGTH]; GetCmdArg(1, sArg, sizeof(sArg)); - int target = FindTarget(client, sArg, true, true); - if (target == -1) - return Plugin_Handled; + int targets[MAXPLAYERS]; + char sTargetName[MAX_TARGET_LENGTH]; + bool bIsML; - if (!g_bBanned[target]) + int count = ProcessTargetString(sArg, client, targets, sizeof(targets), COMMAND_FILTER_CONNECTED | COMMAND_FILTER_NO_BOTS, sTargetName, sizeof(sTargetName), bIsML); + if (count <= 0) { - ReplyToCommand(client, "[SM] %N is not couch-banned.", target); - return Plugin_Handled; + ReplyToTargetError(client, count); + return; } - DoUnban(client, target); - return Plugin_Handled; + int affected = 0; + int last = 0; + + for (int i = 0; i < count; i++) + { + int target = targets[i]; + if (!IsClientInGame(target) || g_bBanned[target] == bBan) + continue; + + if (bBan) + DoBan(client, target); + else + DoUnban(client, target); + + affected++; + last = target; + } + + if (affected == 0) + { + if (count == 1) + ReplyToCommand(client, PREFIX_PLAIN ... " %N is %s couch-banned.", targets[0], bBan ? "already" : "not"); + else + ReplyToCommand(client, PREFIX_PLAIN ... " No matching players to couch-%s.", bBan ? "ban" : "unban"); + + return; + } + + // Announced to everyone so players know why the couch stopped reacting to someone. A single + // player is named even when they were picked out of a filter. + if (affected == 1) + { + if (bBan) + ShowActivity2(client, PREFIX, "\x01couch-banned \x04%N\x01 until the end of the map.", last); + else + ShowActivity2(client, PREFIX, "\x01removed couch-ban from \x04%N\x01.", last); + } + else if (bIsML) + { + if (bBan) + ShowActivity2(client, PREFIX, "\x01couch-banned \x04%t\x01 until the end of the map.", sTargetName); + else + ShowActivity2(client, PREFIX, "\x01removed couch-ban from \x04%t\x01.", sTargetName); + } + else + { + if (bBan) + ShowActivity2(client, PREFIX, "\x01couch-banned \x04%s\x01 until the end of the map.", sTargetName); + else + ShowActivity2(client, PREFIX, "\x01removed couch-ban from \x04%s\x01.", sTargetName); + } } bool RequireCore(int client) @@ -197,7 +240,7 @@ bool RequireCore(int client) if (g_bCoreLoaded) return true; - ReplyToCommand(client, "[SM] The Couch core plugin is not loaded."); + ReplyToCommand(client, PREFIX_PLAIN ... " The Couch core plugin is not loaded."); return false; } @@ -205,14 +248,13 @@ bool RequireCore(int client) // Actions // --------------------------------------------------------------------------------------------- +// The announcement is the command's job, so a filter doesn't produce a line per player. 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); } @@ -222,7 +264,6 @@ void DoUnban(int client, int target) 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); } diff --git a/Couch/scripting/CouchHud.sp b/Couch/scripting/CouchHud.sp index aa4de42..573130c 100644 --- a/Couch/scripting/CouchHud.sp +++ b/Couch/scripting/CouchHud.sp @@ -2,6 +2,11 @@ #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 @@ -18,6 +23,24 @@ // 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 @@ -35,11 +58,19 @@ float g_fExpire[MAXPLAYERS + 1]; // GetGameTime() at which this client drops of 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]; @@ -65,6 +96,17 @@ public void OnPluginStart() 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. @@ -73,6 +115,11 @@ public void OnMapStart() 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++) { @@ -80,6 +127,7 @@ public void OnMapStart() g_fLastHit[i] = 0.0; g_fGrenadeUntil[i] = 0.0; g_fNextUseSend[i] = 0.0; + g_bZombieHit[i] = false; } } @@ -89,6 +137,7 @@ public void OnClientPutInServer(int client) g_fLastHit[client] = 0.0; g_fGrenadeUntil[client] = 0.0; g_fNextUseSend[client] = 0.0; + g_bZombieHit[client] = false; } public void OnClientDisconnect(int client) @@ -99,6 +148,7 @@ public void OnClientDisconnect(int client) g_fLastHit[client] = 0.0; g_fGrenadeUntil[client] = 0.0; g_fNextUseSend[client] = 0.0; + g_bZombieHit[client] = false; if (bListed) QueueFlush(); @@ -114,6 +164,8 @@ public void OnLibraryRemoved(const char[] name) { if (StrEqual(name, "Couch")) ResetAll(); + else if (StrEqual(name, "zombiereloaded")) + g_bZRLoaded = false; } // --------------------------------------------------------------------------------------------- @@ -129,6 +181,7 @@ public void Couch_OnCouchDamaged(int couch, int attacker, float damage) 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(); } @@ -142,6 +195,9 @@ public void Couch_OnCouchInteraction(int couch, int client, CouchMethod method, 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; @@ -174,6 +230,27 @@ 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 // --------------------------------------------------------------------------------------------- @@ -203,7 +280,7 @@ void Flush() return; } - if (BuildHudText(g_sHudText, sizeof(g_sHudText)) == 0) + if (!BuildHudText(g_sHudText, sizeof(g_sHudText))) { ClearAll(); return; @@ -215,8 +292,20 @@ void Flush() 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. - SetHudTextParams(0.8, 0.2, g_fLastExpiry - GetGameTime(), 255, 255, 255, 255, 0, 0.0, 0.0, 0.0); + // 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++) { @@ -231,9 +320,10 @@ void Send() 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) +// 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]; @@ -276,13 +366,26 @@ int BuildHudText(char[] text, int maxlen) g_fLastExpiry = g_fExpire[best]; } - if (total == 0) + g_bHpLine = (Couch_IsHpEnabled() && Couch_GetHp() < Couch_GetMaxHp()); + + text[0] = '\0'; + + if (g_bHpLine) { - text[0] = '\0'; - return 0; + // 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); } - strcopy(text, maxlen, "Shooting couch:"); + 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]; @@ -294,8 +397,8 @@ int BuildHudText(char[] text, int maxlen) GetClientName(order[rows], sName, sizeof(sName)); TruncateUtf8(sName, HUD_NAME_BYTES); - char sLine[HUD_NAME_BYTES + sizeof(HUD_GRENADE_TAG) + 1]; - FormatEx(sLine, sizeof(sLine), "\n%s%s", (g_fGrenadeUntil[order[rows]] > fNow) ? HUD_GRENADE_TAG : "", sName); + 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) @@ -321,7 +424,7 @@ int BuildHudText(char[] text, int maxlen) text[rowStart[rows]] = '\0'; } - return total; + return true; } // Cuts to at most maxBytes without leaving a partial multi-byte character on the end. @@ -347,8 +450,30 @@ public Action Timer_Expire(Handle timer) return Plugin_Stop; } - // Only resend when a name has lapsed. - if (GetGameTime() >= g_fNextExpiry) + // 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; @@ -378,7 +503,14 @@ void ResetAll() 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(); } diff --git a/Couch/scripting/include/Couch.inc b/Couch/scripting/include/Couch.inc index 143dd7a..92727e7 100644 --- a/Couch/scripting/include/Couch.inc +++ b/Couch/scripting/include/Couch.inc @@ -4,6 +4,32 @@ #define _Couch_included +// Chat prefix shared by the whole couch plugin set. The trailing colour code tints the token that +// follows it (a name or value); messages switch back with \x01. PREFIX_PLAIN is for console +// replies, which don't render colour codes. +#define PREFIX "\x07CD853F[Couch] \x04" +#define PREFIX_PLAIN "[Couch]" + +// Non-negative integer with a comma every three digits: 1532 -> "1,532". +stock void FormatThousands(int value, char[] buffer, int maxlen) +{ + char sDigits[16]; + int len = IntToString(value, sDigits, sizeof(sDigits)); + + int out = 0; + for (int i = 0; i < len && out < maxlen - 1; i++) + { + // A comma goes before every digit that starts a new group of three from the right. + if (i > 0 && (len - i) % 3 == 0) + buffer[out++] = ','; + + if (out < maxlen - 1) + buffer[out++] = sDigits[i]; + } + + buffer[out] = '\0'; +} + // How a player moved the couch, passed to Couch_OnCouchInteraction. enum CouchMethod { @@ -58,6 +84,25 @@ forward void Couch_OnCouchInteraction(int couch, int client, CouchMethod method, */ forward void Couch_OnClientBlocked(int couch, int client); +/** + * Called whenever the couch's HP changes: zombie hits, regen ticks, admin sets, resets to full + * on spawn and round start, and when the HP feature is switched on or off. + * + * @param couch Entity index of the couch, or -1 if the feature was switched off while there was none. + * @param hp Current HP. + * @param maxhp Maximum HP, sm_couch_hp_max. + */ +forward void Couch_OnCouchHpChanged(int couch, int hp, int maxhp); + +/** + * Called when the couch's HP reaches 0, before the humans are killed and the couch is removed. + * Couch_OnCouchRemoved follows. + * + * @param couch Entity index of the couch; still valid during this call. + * @param attacker Client index of the zombie whose hit broke it, or 0 when an admin set the HP to 0. + */ +forward void Couch_OnCouchBroken(int couch, int attacker); + /** * @return Entity index of the couch, or -1 if there is none. */ @@ -114,6 +159,16 @@ native bool Couch_IsEffectEnabled(); */ native void Couch_SetEffectEnabled(bool enabled); +/** + * Whether the sm_beacon-style rings on the couch are on. Mirrors sm_couch_beacon. + */ +native bool Couch_IsBeaconEnabled(); + +/** + * Switches the beacon rings on or off. Writes sm_couch_beacon. + */ +native void Couch_SetBeaconEnabled(bool enabled); + /** * Whether only pistols and knives may move the couch. Mirrors sm_couch_pistol_only. */ @@ -151,6 +206,43 @@ native void Couch_SetClientBlocked(int client, bool blocked); */ native bool Couch_IsClientBlocked(int client); +/** + * Whether couch HP is on: zombie hits wear the couch down and at 0 HP it breaks, killing every + * human. Mirrors sm_couch_hp. + */ +native bool Couch_IsHpEnabled(); + +/** + * Switches couch HP on or off. Turning it on sets the HP to full. Writes sm_couch_hp. + */ +native void Couch_SetHpEnabled(bool enabled); + +/** + * @return Current couch HP, or 0 if there is no couch. + */ +native int Couch_GetHp(); + +/** + * Sets the couch's HP, clamped to 0..Couch_GetMaxHp(). Setting 0 breaks the couch as a zombie + * hit would, provided the HP feature is on and a couch exists. + * + * @param hp New HP. + */ +native void Couch_SetHp(int hp); + +/** + * @return Maximum couch HP. Mirrors sm_couch_hp_max. + */ +native int Couch_GetMaxHp(); + +/** + * Sets the maximum couch HP. A couch at full HP stays full; otherwise the HP is clamped to the + * new maximum. Writes sm_couch_hp_max. + * + * @param maxhp New maximum, at least 1. + */ +native void Couch_SetMaxHp(int maxhp); + public SharedPlugin __pl_Couch = { @@ -175,12 +267,20 @@ public SharedPlugin __pl_Couch = MarkNativeAsOptional("Couch_Remove"); MarkNativeAsOptional("Couch_IsEffectEnabled"); MarkNativeAsOptional("Couch_SetEffectEnabled"); + MarkNativeAsOptional("Couch_IsBeaconEnabled"); + MarkNativeAsOptional("Couch_SetBeaconEnabled"); MarkNativeAsOptional("Couch_IsPistolOnly"); MarkNativeAsOptional("Couch_SetPistolOnly"); MarkNativeAsOptional("Couch_IsUseAllowed"); MarkNativeAsOptional("Couch_SetUseAllowed"); MarkNativeAsOptional("Couch_SetClientBlocked"); MarkNativeAsOptional("Couch_IsClientBlocked"); + MarkNativeAsOptional("Couch_IsHpEnabled"); + MarkNativeAsOptional("Couch_SetHpEnabled"); + MarkNativeAsOptional("Couch_GetHp"); + MarkNativeAsOptional("Couch_SetHp"); + MarkNativeAsOptional("Couch_GetMaxHp"); + MarkNativeAsOptional("Couch_SetMaxHp"); } #endif