perks/PerkXpStillGain.cs

A player perk called Researcher that grants bonus XP per second while the player is not moving and reduces reload speed while still. It spawns particle effects, shows floater XP text periodically, updates display/visuals, and modifies player stats.

File Access
using Sandbox;
using System;
using System.Drawing;
using static Sandbox.VertexLayout;

[Perk( Rarity.Legendary, locked: true, minUnlocksReq: 2, alwaysOfferDebug: false )]
public class PerkXpStillGain : Perk
{
	private enum Mod { XpPerSecond, ReloadSpeedStill };

	private TimeSince _timeSinceHighlight;

	private GameObject _particlesGo;
	private ParticleEmitter _particleEmitter;

	// todo: every X seconds, Y chance to spawn an xp coin, + remove the non-animated logic for xp bar?

	private bool _isStill;

	static PerkXpStillGain()
	{
		Register<PerkXpStillGain>(
			name: "Researcher",
			imagePath: "textures/icons/vector/xp_still_gain.png",
			description: level => $"While not moving,\n+{GetValue( level, Mod.XpPerSecond ).ToString( "0.##" )} xp/s and -{GetValue( level, Mod.ReloadSpeedStill, true )}% reload speed",
			upgradeDescription: level => $"While not moving, +{GetValue( level - 1, Mod.XpPerSecond ).ToString( "0.##" )}→{GetValue( level, Mod.XpPerSecond ).ToString( "0.##" )} xp/s and -{GetValue( level - 1, Mod.ReloadSpeedStill, true )}%→-{GetValue( level, Mod.ReloadSpeedStill, true )}% reload speed"
		);
	}

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

		ShouldUpdate = true;

		HighlightColor = new Color( 0.3f, 0.3f, 1f );
		HighlightDuration = 0.25f;
		HighlightOpacity = 0.75f;

		DisplayCooldownColor = new Color( 0f, 0f, 0.5f, 0.3f );
		DisplayTextColor = new Color( 0.4f, 0.4f, 1f );

		_particlesGo = GameObject.Clone( "prefabs/effects/xp_still.prefab", new CloneConfig { StartEnabled = true, Parent = Player.GameObject } );
		_particlesGo.LocalPosition = new Vector3( 0f, 0f, 40f );

		_particleEmitter = _particlesGo.GetComponent<ParticleEmitter>();

		_isStill = true;
	}

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

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

	private static float GetValue( int level, Mod mod, bool isPercent = false )
	{
		switch ( mod )
		{
			case Mod.XpPerSecond:
			default:
				return 0.3f * level;
			case Mod.ReloadSpeedStill:
				return isPercent
					? 5f + 5f * level
					: 1f - (0.05f + 0.05f * level);
		}
	}

	private float _accumulatedXp;

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

		if ( !Player.IsMoving )
		{
			var xp = GetValue( Level, Mod.XpPerSecond ) * Player.Stats[PlayerStat.XpGainMultiplier] * dt;

			if ( Manager.Instance.IsCommunismActive )
			{
				Manager.Instance.AddCommunismXp( xp, XpSource.Passive, spawnFloater: false, playSfx: false );

				// todo: floater?
			}
			else
			{
				Player.AddXp( xp, XpSource.Passive );

				_accumulatedXp += xp;
			}

			if( _timeSinceHighlight > 0.25f )
			{
				Highlight();
				_timeSinceHighlight = 0f;
			}

			if( _accumulatedXp > 1f )
			{
				_accumulatedXp -= 1f;

				var pos = Player.WorldPosition.WithZ( 60f );
				var color = new Color( 0.3f, 0.3f, 1f );
				float size = 1.3f;
				Manager.Instance.SpawnFloaterTextRpc( pos, $"1 XP", color, size, FloaterType.Xp );
			}

			if( !_isStill )
			{
				_isStill = true;

				ShowParticles();
			}
		}
		else
		{
			if ( _isStill )
			{
				_isStill = false;

				HideParticles();
			}
		}

		Player.Stats[PlayerStat.DisableXpBarTransition] = Player.IsMoving ? 0f : 1f;

		DisplayText = !Player.IsMoving ? $"+{GetValue( Level, Mod.XpPerSecond ).ToString( "0.#" )}" : " ";
		IconScale = 1f + (Utils.FastSin( RealTime.Now * 10f ) * 0.02f) * (Player.IsMoving ? 0f : 1f);
		DisplayCooldown = Player.IsMoving ? 0f : 1f;
		IconAngleOffset = Player.IsMoving ? 0f : Utils.FastSin( RealTime.Now * 5f ) * 3f;
	}

	void ShowParticles()
	{
		//if ( _particlesGo.IsValid() )
		//	_particlesGo.Enabled = true;

		if ( _particleEmitter.IsValid() )
			_particleEmitter.Enabled = true;
	}

	void HideParticles()
	{
		//if ( _particlesGo.IsValid() )
		//	_particlesGo.Enabled = false;

		if ( _particleEmitter.IsValid() )
			_particleEmitter.Enabled = false;
	}

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

		Player.Stats[PlayerStat.DisableXpBarTransition] = 0f;

		if ( _particlesGo.IsValid() )
			_particlesGo.Destroy();
	}

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

		HideParticles();
	}

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

		_isStill = true;

		ShowParticles();
	}
}