cs2-outnumbered/Outnumbered/Abilities.cs
Kamal Tufekcic d701598350
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s
initial commit
2026-07-05 13:28:35 +03:00

239 lines
14 KiB
C#

using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.Logging;
using Outnumbered.Config;
using Outnumbered.Data;
using Outnumbered.Engine;
namespace Outnumbered;
// Killstreak abilities, indexed 0..4 in streak/prestige order: No Reload, Adrenaline, Overcharge, Bloodthirst, Berserk.
// INPUT: slot6-10 command listeners DON'T fire on this build (CS2 selects grenades client-side), and a held grenade
// is still throwable via mouse-wheel/lastinv. So instead we grant the ability's grenade only while it's castable, and
// the instant that grenade becomes the ACTIVE weapon (any selection path: key, wheel, lastinv) we cast the ability,
// confiscate the grenade, and switch back to primary — so it can never be thrown.
public sealed partial class OutnumberedPlugin
{
// Internal ability index. Streak tiers and Prestige-I..V unlocks follow this order.
private const int AbNoReload = 0, AbAdrenaline = 1, AbOvercharge = 2, AbBloodthirst = 3, AbBerserk = 4;
// The ability REGISTRY — one row per killstreak ability, the single place their identity lives. Def selects
// the LIVE config block so !og_reload tunables apply. Grenade binds stay in Config.Abilities.AbilityGrenades (JSON).
// Adding/reordering an ability = one row here (+ matching grenade in the JSON list). SEE ALSO two index-coupled
// sites that must stay reconciled with this ORDER: Ranks.PrestigeColorIdx (prestige tag colours reference ability
// indices) and AbilityUnlocked's `pd.Prestige >= i+1` (Prestige I..V unlock abilities 0..4 off-streak).
internal sealed record AbilityInfo(int Index, string Name, string Short, string KeyNum, string KeyHint,
Func<AbilitiesConfig, AbilityDef> Def);
internal static readonly AbilityInfo[] AbilityRegistry =
{
new(AbNoReload, "No Reload", "NoReload", "6", "HE / key 6", c => c.NoReload),
new(AbAdrenaline, "Adrenaline", "Adren", "7", "Flash / key 7", c => c.Adrenaline),
new(AbOvercharge, "Overcharge", "Overchg", "8", "Smoke / key 8", c => c.Overcharge),
new(AbBloodthirst, "Bloodthirst", "Bloodth", "0", "Molotov / key 0", c => c.Bloodthirst),
new(AbBerserk, "Berserk", "Berserk", "9", "Decoy / key 9", c => c.Berserk),
};
internal static int AbilityCount => AbilityRegistry.Length;
private AbilityDef AbilityCfg(int i) => AbilityRegistry[i].Def(Config.Abilities);
private string AbilityGrenadeName(int i)
{
var list = Config.Abilities.AbilityGrenades;
return i >= 0 && i < list.Count ? list[i] : "";
}
// Reverse of AbilityGrenadeName: which ability does this grenade weapon trigger? -1 if not an ability grenade.
private int AbilityForGrenade(string designerName)
{
var list = Config.Abilities.AbilityGrenades;
for (int i = 0; i < list.Count && i < AbilityCount; i++)
if (list[i] == designerName) return i;
return -1;
}
private CounterStrikeSharp.API.Modules.Timers.Timer? _grenadeTimer;
private void Initialize_Abilities()
{
if (AbilityRegistry.Length > PlayerData.AbilitySlots) // the per-player state arrays must hold every ability index
Logger.LogError("Outnumbered: AbilityRegistry has {N} rows but PlayerData.AbilitySlots is {Slots} — bump AbilitySlots.", AbilityRegistry.Length, PlayerData.AbilitySlots);
// Ranks.PrestigeColorIdx is index-coupled to the ability order; a reorder/addition that references a row past the
// registry would mis-colour or crash the prestige tag. Catch it loudly at load (the registry's "one-row" promise).
foreach (var set in PrestigeColorIdx)
foreach (var i in set)
if (i >= AbilityCount)
Logger.LogError("Outnumbered: Ranks.PrestigeColorIdx references ability {I} but only {N} abilities exist.", i, AbilityCount);
// AbilityForGrenade binds grenade->ability POSITIONALLY via a first-match scan; with 8 instances sharing one JSON
// a wrong-length or duplicated list silently casts the wrong/no ability on every server. Validate at load (warn).
var grenades = Config.Abilities.AbilityGrenades;
if (grenades.Count != AbilityCount)
Logger.LogWarning("Outnumbered: AbilityGrenades has {N} entries but there are {C} abilities — extras are ignored and missing rows get no grenade key.", grenades.Count, AbilityCount);
var seenGrenades = new HashSet<string>(StringComparer.Ordinal);
foreach (var g in grenades)
if (g.Length > 0 && !seenGrenades.Add(g))
Logger.LogWarning("Outnumbered: AbilityGrenades lists '{G}' more than once — AbilityForGrenade maps it to the first matching ability only.", g);
// The two other ability-indexed presentation lists: AbilityChat (prestige-tag colours) wraps, AbilityIcons falls
// back to the registry Short label — warn so a new ability doesn't silently mis-colour / lose its HUD icon.
if (AbilityChat.Length < AbilityCount)
Logger.LogWarning("Outnumbered: AbilityChat has {N} colours but there are {C} abilities — the prestige tag colour wraps for the extras.", AbilityChat.Length, AbilityCount);
if (Config.Hud.AbilityIcons.Count < AbilityCount)
Logger.LogWarning("Outnumbered: Hud.AbilityIcons has {N} entries but there are {C} abilities — missing rows fall back to the registry Short label.", Config.Hud.AbilityIcons.Count, AbilityCount);
RegisterEventHandler<EventWeaponFire>(OnWeaponFire_Abilities);
// OnTick is driven by OnTick_All (shared roster walk); OnTick_Abilities is invoked from there.
// Reconcile held ability-grenades with castable abilities (grant when ready, remove otherwise).
_grenadeTimer = AddTimer(0.5f, ReconcileAllGrenades, CounterStrikeSharp.API.Modules.Timers.TimerFlags.REPEAT);
}
private void Shutdown_Abilities()
{
_grenadeTimer?.Kill();
}
// ---- state queries (also read by the damage/lifesteal hooks + the HUD) ----
private static bool AbilityActive(PlayerData pd, int i) => pd.AbilityActiveUntil[i] > Server.CurrentTime;
// Overload taking a pre-read clock — lets Snapshot() read Server.CurrentTime ONCE for its 3 ability checks (the
// clock can't advance within one synchronous build, so it's bit-identical to 3 separate reads).
private static bool AbilityActive(PlayerData pd, int i, double now) => pd.AbilityActiveUntil[i] > now;
private static bool AbilityReady(PlayerData pd, int i) => Server.CurrentTime >= pd.AbilityReadyAt[i];
// Pre-read-clock overload (mirrors AbilityActive(pd,i,now)) — lets the HUD read Server.CurrentTime ONCE per build.
private static bool AbilityReady(PlayerData pd, int i, double now) => now >= pd.AbilityReadyAt[i];
private bool AbilityUnlocked(PlayerData pd, int i) =>
pd.Streak >= AbilityCfg(i).StreakReq || pd.Prestige >= i + 1; // Prestige I..V unlock 0..4 off-streak
// Castable right now = unlocked AND off cooldown. (AbilityReadyAt is set cooldown-ahead on use, so this is
// false through the active window too.) Drives both casting and which grenade the player should hold.
private bool AbilityUsable(PlayerData pd, int i) => AbilityUnlocked(pd, i) && AbilityReady(pd, i);
// ---- grenade-key lifecycle: hold the ability's grenade exactly while that ability is castable ----
private void ReconcileAllGrenades()
{
if (!Config.Abilities.Enabled || !Config.Abilities.GrenadeKeyInput) return;
foreach (var p in Utilities.GetPlayers())
{
if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path)
if (ShopInputLocked(p.Slot)) continue; // shop owns the inputs while open (+ a grace after close)
if (PdOf(p) is { } pd) ReconcileGrenades(p, pd);
}
}
private void ReconcileGrenades(CCSPlayerController p, PlayerData pd)
{
for (int i = 0; i < AbilityCount; i++)
{
string g = AbilityGrenadeName(i);
if (g.Length == 0) continue;
bool shouldHold = AbilityUsable(pd, i);
bool holds = Inventory.Holds(p, g);
if (shouldHold && !holds) p.GiveNamedItem(g); // AutoSwitchTo=false on grenades -> no weapon switch
else if (!shouldHold && holds) p.RemoveItemByDesignerName(g);
}
}
// ---- input: an ability grenade becoming the active weapon = the player tried to use it ----
private void OnTick_Abilities(List<CCSPlayerController> players)
{
if (!Config.Abilities.Enabled || !Config.Abilities.GrenadeKeyInput) return;
foreach (var p in players)
{
if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path)
if (ShopInputLocked(p.Slot)) continue; // shop owns the inputs while open (+ a grace after close)
var active = Inventory.ActiveWeapon(p);
if (active is null) continue;
int i = AbilityForGrenade(active.DesignerName);
if (i < 0) continue; // active weapon isn't an ability grenade — nothing to do
var pd = PdOf(p);
if (pd is null) continue;
// Cast if castable; either way confiscate the grenade and switch to the player's real weapon so it's
// never thrown and we never leave a grenade active. MUST be the best weapon they actually hold, not a
// hardcoded slot1 — on the Gun Game knife rung there's no primary, so slot1 would no-op and the next
// grenade would stay active, casting the NEXT ability every tick.
if (AbilityUsable(pd, i)) TryActivateAbility(p, i);
p.RemoveItemByDesignerName(AbilityGrenadeName(i)); // no-op if TryActivateAbility already removed it
p.ExecuteClientCommand(PreferredSlotCmd(p)); // primary -> pistol -> knife (whatever exists)
}
}
// ---- activation / usability ----
private void TryActivateAbility(CCSPlayerController p, int i)
{
if (!Config.Abilities.Enabled || i < 0 || i >= AbilityCount) return;
var pd = PdOf(p);
if (pd is null || !p.PawnIsAlive) return;
var def = AbilityCfg(i);
string name = AbilityRegistry[i].Name;
double now = Server.CurrentTime;
if (AbilityActive(pd, i)) { p.PrintToChat($"[Outnumbered] {name} is already active."); return; }
if (!AbilityReady(pd, i)) { p.PrintToChat($"[Outnumbered] {name} on cooldown ({pd.AbilityReadyAt[i] - now:F0}s)."); return; }
if (!AbilityUnlocked(pd, i))
{
p.PrintToChat($"[Outnumbered] {name} locked — need streak {def.StreakReq} (or Prestige {Roman(i + 1)}). Streak is {pd.Streak}.");
return;
}
pd.AbilityActiveUntil[i] = now + def.Duration;
// survival -Ability-Cooldown card shortens the cooldown (0 outside a run / without the card). Cooldown starts on
// use and keeps ticking through death.
double cd = def.Cooldown * Math.Max(0.0, 1.0 - EffectCardMag(pd, CardKeys.AbilityCdr) / 100.0);
pd.AbilityReadyAt[i] = now + cd;
if (Config.Abilities.GrenadeKeyInput) p.RemoveItemByDesignerName(AbilityGrenadeName(i)); // key dies now; reconciles back after cooldown
OnAbilityActivated(p, i);
p.PrintToChat($"[Outnumbered] {name} ACTIVE for {def.Duration:F0}s!");
}
private void OnAbilityActivated(CCSPlayerController p, int i)
{
if (i == AbNoReload) RefillActiveWeapon(p, fillClip: true); // top the clip immediately
PlaySound(p, Config.Sounds.AbilityActivate);
}
// ---- weapon ammo (No Reload + baseline infinite reserve) ----
private HookResult OnWeaponFire_Abilities(EventWeaponFire ev, GameEventInfo info)
{
var p = ev.Userid;
if (p is not { IsValid: true } || p.IsBot) return HookResult.Continue;
var pd = PdOf(p);
if (pd is null) return HookResult.Continue;
bool noReload = Config.Abilities.Enabled && AbilityActive(pd, AbNoReload);
if (Config.Abilities.InfiniteReserve || noReload) RefillActiveWeapon(p, fillClip: noReload);
return HookResult.Continue;
}
// SetStateChanged not needed for clip/ammo writes.
private void RefillActiveWeapon(CCSPlayerController p, bool fillClip)
{
var w = Inventory.ActiveWeapon(p);
if (w is null) return;
if (Config.Abilities.InfiniteReserve) w.ReserveAmmo[0] = Config.Abilities.InfiniteReserveAmount;
if (fillClip)
{
int max = Inventory.MaxClip(w);
if (max > 0) w.Clip1 = max;
}
}
// ---- !abilities / !perks ----
private void ShowAbilities(CCSPlayerController p)
{
var pd = PdOf(p);
if (pd is null) return;
double now = Server.CurrentTime;
p.PrintToChat("[Outnumbered] Killstreak abilities — select that grenade to cast, OR type !abilityN");
p.PrintToChat(" (custom binds? use !ability1..5, or bind a key: bind h css_ability1)");
for (int i = 0; i < AbilityCount; i++)
{
var def = AbilityCfg(i);
string state =
AbilityActive(pd, i) ? $"ACTIVE {pd.AbilityActiveUntil[i] - now:F0}s" :
!AbilityReady(pd, i) ? $"cooldown {pd.AbilityReadyAt[i] - now:F0}s" :
AbilityUnlocked(pd, i) ? "READY" :
$"locked (streak {def.StreakReq} / Prestige {Roman(i + 1)})";
p.PrintToChat($" {AbilityIcon(i)} {AbilityRegistry[i].Name} [{AbilityRegistry[i].KeyHint} or !ability{i + 1}] — {state}");
}
}
}