perks/PerkActiveReload.cs

A Perk component implementing an active reload mechanic. It spawns an active reload UI bar, tracks reload progress, allows early successful reloads with a timing window, applies reload speed modifier, and penalizes the player with health if mistimed.

File Access
using System;
using Sandbox;

[Perk( Rarity.Unique, locked: true, minUnlocksReq: 2, alwaysOfferDebug: false )]
public class PerkActiveReload : Perk
{
	private enum Mod { HealthLost, ReloadSpeed };

	private GameObject _activeReloadBar;

	public bool IsVisible => IsReady && Player.IsReloading && !Player.IsChoosingLevelUpReward;
	public float BarProgress { get; set; }
	public float SectionStart { get; set; }
	public float SectionWidth { get; set; }
	public bool IsReady { get; set; }
	public bool IsBarInSection => BarProgress >= SectionStart && BarProgress <= SectionStart + SectionWidth;

	public float Duration { get; set; }

	// todo: put UI on bottom when in 3rd person mode?

	static PerkActiveReload()
	{
		Register<PerkActiveReload>(
			name: "Active Reload",
			imagePath: "textures/icons/vector/active_reload.png",
			description: level => $"-{GetValue( level, Mod.ReloadSpeed, true )}% reload speed\nPress R to reload early\n-{GetValue( level, Mod.HealthLost )} hp on fail",
			upgradeDescription: level => $"-{GetValue( level - 1, Mod.ReloadSpeed, true )}%→-{GetValue( level, Mod.ReloadSpeed, true )}% reload speed\nPress R to reload early\n-{GetValue( level - 1, Mod.HealthLost )}→-{GetValue( level, Mod.HealthLost )} hp on fail"
			//description: level => $"Press R to reload early\n-{GetValue( level, Mod.HealthLost )} hp on fail",
			//upgradeDescription: level => $"Press R to reload early\n-{GetValue( level - 1, Mod.HealthLost )}→-{GetValue( level, Mod.HealthLost )} hp on fail"
		);
	}
	
	public override void Start()
	{
		base.Start();

		_activeReloadBar = GameObject.Clone( "prefabs/active_reload_bar.prefab", new CloneConfig { StartEnabled = true, Parent = Player.GameObject } );
		var arComponent = _activeReloadBar.GetComponent<ActiveReloadBar>();
		arComponent.Player = Player;
		arComponent.Perk = this;

		ShouldUpdate = true;
	}

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

		Player.Modify( this, PlayerStat.ReloadSpeed, GetValue( Level, Mod.ReloadSpeed ), ModifierType.Mult );
	}

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

		if ( !IsVisible )
			return;

		BarProgress = Player.ReloadProgress;

		if( Input.Pressed("R") && !Manager.Instance.IsPaused )
		{
			float leniency = 0.008f;
			if ( BarProgress > SectionStart - leniency && BarProgress < SectionStart + SectionWidth + leniency )
			{
				Player.ReloadInstantly();

				HighlightColor = new Color( 0.7f, 0.7f, 1f );
				HighlightDuration = 0.25f;
				HighlightOpacity = 1.5f;
				Highlight();

				IconScale = Game.Random.Float( 1.1f, 1.2f );
				IconAngleOffset = Game.Random.Float( 5f, 10f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);
			}
			else
			{
				Manager.Instance.PlaySfxUI( "error2", pitch: 0.5f, volume: 0.95f );

				Player.Damage( GetValue( Level, Mod.HealthLost ), DamageType.Self, Player.Position2D, Player.Velocity.Normal, upwardAmount: 0f, force: 0f, ragdollForce: 1f, enemySource: null, enemyType: EnemyType.None );

				HighlightColor = new Color( 1f, 0.2f, 0.35f );
				HighlightDuration = 0.35f;
				HighlightOpacity = 3f;
				Highlight();

				IconScale = Game.Random.Float( 1.2f, 1.3f );
				IconAngleOffset = Game.Random.Float( 10f, 20f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);
			}

			IsReady = false;
		}
	}

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

		if ( Player.IsChoosingLevelUpReward )
			return;

		BarProgress = 0f;

		SectionWidth = Game.Random.Float(0.035f, 0.195f);
		SectionStart = Game.Random.Float( 0.1f, 1f - (SectionWidth + 0.1f) );

		IsReady = true;
	}

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

		IsReady = false;
	}

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

		IsReady = false;
	}

	public override void Remove( bool restart = false )
	{
		base.Remove( restart );

		_activeReloadBar.Destroy();
	}

	private static float GetValue( int level, Mod mod, bool isPercent = false )
	{
		switch ( mod )
		{
			case Mod.HealthLost:
			default:
				return 10f;
			case Mod.ReloadSpeed:
				return isPercent
					? 15f * level
					: 1f - (0.15f * level);
		}
	}
}