perks/PerkKillAttackSpeed.cs

Perk class that grants temporary attack and reload speed buffs on kills. It tracks kill timestamps in a queue, applies stacked multiplicative buffs up to a cap, updates display text/cooldown, and expires stacks after a duration.

Networking
using System;

[Perk( Rarity.Epic, locked: true, alwaysOfferDebug: false )]
public class PerkKillAttackSpeed : Perk
{
	private Queue<float> _killTimes = new();

	private enum Mod { AttackReloadSpeed, MaxBuff, Time };

	static PerkKillAttackSpeed()
	{
		Register<PerkKillAttackSpeed>(
			name: "Bloodlust",
			imagePath: "textures/icons/vector/kill_attack_speed.png",
			description: level => $"+{GetValue( level, Mod.AttackReloadSpeed, true ).ToString( "0.#" )}% attack/reload speed for\n{GetValue( level, Mod.Time ).ToString( "0.#" )}s on kill\n(max: {(int)GetValue( level, Mod.MaxBuff, true )}%)",
			upgradeDescription: level => $"+{GetValue( level - 1, Mod.AttackReloadSpeed, true ).ToString( "0.#" )}%→{GetValue( level, Mod.AttackReloadSpeed, true ).ToString( "0.#" )}% attack/reload speed for\n{GetValue( level - 1, Mod.Time ).ToString( "0.#" )}→{GetValue( level, Mod.Time ).ToString( "0.#" )}s on kill\n(max: {(int)GetValue( level - 1, Mod.MaxBuff, true )}%→{(int)GetValue( level, Mod.MaxBuff, true )}%)"
		);
	}

	public override void Start()
	{
		base.Start();

		ShouldUpdate = true;
		DisplayCooldownColor = new Color( 0.2f, 0.3f, 1f, 3f );

		HighlightColor = new Color( 0.6f, 0.6f, 1f );
		HighlightDuration = 0.15f;
		HighlightOpacity = 1f;
	}

	public override void Refresh()
	{
		base.Refresh();

	}

	public override void Update( float dt )
	{
		base.Update( dt );

		if ( _killTimes.Count > 0 )
		{
			if ( Time.Now > _killTimes.First() + GetValue( Level, Mod.Time ) )
			{
				_killTimes.Dequeue();
				RefreshBuffs();
			}
		}

		float maxPercent = GetValue( Level, Mod.MaxBuff, true );
		float buffPercent = MathF.Min( _killTimes.Count * GetValue( Level, Mod.AttackReloadSpeed, true ), maxPercent );
		DisplayText = _killTimes.Count > 0 ? $"{buffPercent}%" : " ";
		DisplayCooldown = Utils.Map( buffPercent, 0f, maxPercent, 0f, 1f );

		// todo: instead use displaycooldown to show time left on buff

		//DebugOverlay.Text($"{Player.Stats[PlayerStat.AttackSpeed]}, {Player.Stats[PlayerStat.ReloadSpeed]}, {maxPercent}", Player.Position, 0f, float.MaxValue);
	}

	private static float GetValue( int level, Mod mod, bool isPercent = false )
	{
		switch ( mod )
		{
			case Mod.AttackReloadSpeed:
			default:
				return isPercent
					? 1.5f + 0.5f * level
					: 0.015f + 0.005f * level;
			case Mod.MaxBuff:
				return isPercent
					? 10f + 20f * level
					: 0.10f + 0.20f * level;
			case Mod.Time:
				return 1.5f + 0.5f * level;
		}
	}

	public override void OnKill( Enemy enemy, DamageType damageType, bool countsAsKill )
	{
		base.OnKill( enemy, damageType, countsAsKill );
		if ( !countsAsKill ) return;

		_killTimes.Enqueue( Time.Now );
		RefreshBuffs();

		Highlight();
	}

	void RefreshBuffs()
	{
		float max = 1f + GetValue( Level, Mod.MaxBuff );
		float speed = MathF.Min( 1f + _killTimes.Count * GetValue( Level, Mod.AttackReloadSpeed ), max );
		Player.Modify( this, PlayerStat.AttackSpeed, speed, ModifierType.Mult );
		Player.Modify( this, PlayerStat.ReloadSpeed, speed, ModifierType.Mult );
	}
}