239 lines
14 KiB
C#
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}");
|
|
}
|
|
}
|
|
}
|