perks/PerkBurstHeal.cs

A rare perk component that heals nearby players when the owner stands still for 2 seconds. It manages readiness and cooldown, displays particle indicators, spawns a visual ring and plays a sound, heals the owner and nearby players via RPCs, and cleans up particles on removal or death.

NetworkingFile Access
using Sandbox;
using System;
using System.IO;

[Perk( Rarity.Rare, locked: true, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.Aoe })]
public class PerkBurstHeal : Perk
{
	private enum Mod { Cooldown, HpAmount };

	private bool _isReady;
	private float _stillTimer;
	private float _cooldownTimer;

	private const float HEAL_RADIUS = 180f;
	private const float DISPLAY_RADIUS = 50f;

	private ParticleEffect _particleEffect;
	private ParticleSpriteRenderer _particleRenderer;

	private ParticleEffect _particleEffectBg;
	private ParticleSpriteRenderer _particleRendererBg;

	private float _bgCurrOpacity;

	private const float STILL_TIME = 2f;


	static PerkBurstHeal()
	{
		Register<PerkBurstHeal>(
			name: "Burst Heal",
			imagePath: "textures/icons/vector/burse_heal.png",
			description: level => $"Stop moving for {STILL_TIME}s to heal nearby players for {(int)GetValue( level, Mod.HpAmount)} hp __ (cooldown: {GetValue( level, Mod.Cooldown )}s)",
			upgradeDescription: level => $"Stop moving for {STILL_TIME}s to\nheal nearby players for {(int)GetValue( level - 1, Mod.HpAmount )}→{(int)GetValue( level, Mod.HpAmount )} hp\n(cooldown: {GetValue( level - 1, Mod.Cooldown )}→{GetValue( level, Mod.Cooldown )}s)"
		);
	}

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

		ShouldUpdate = true;

		var particleGo = GameObject.Clone( "prefabs/effects/burst_heal_indicator.prefab", new CloneConfig { StartEnabled = true, Parent = Player.GameObject } );
		particleGo.LocalPosition = new Vector3( 0f, 0f, 4f );
		_particleEffect = particleGo.GetComponent<ParticleEffect>();
		_particleRenderer = particleGo.GetComponent<ParticleSpriteRenderer>();
		_particleRenderer.Scale = 0f;

		var particleBgGo = GameObject.Clone( "prefabs/effects/burst_heal_indicator_bg.prefab", new CloneConfig { StartEnabled = true, Parent = Player.GameObject } );
		particleBgGo.LocalPosition = new Vector3( 0f, 0f, 3.5f );
		_particleEffectBg = particleBgGo.GetComponent<ParticleEffect>();
		_particleRendererBg = particleBgGo.GetComponent<ParticleSpriteRenderer>();
		_particleRendererBg.Scale = DISPLAY_RADIUS * Player.LocalScale.x;

		HighlightColor = new Color( 0.7f, 0.7f, 1f );
		HighlightDuration = 0.25f;
		HighlightOpacity = 4f;
	}

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

		_isReady = true;
		_stillTimer = 0f;
		_bgCurrOpacity = 0f;
	}

	private static float GetValue( int level, Mod mod, bool isPercent = false )
	{
		switch ( mod )
		{
			case Mod.Cooldown:
			default:
				return 105f - 15f * level;
			case Mod.HpAmount:
				return 20f + 5f * level;
		}
	}

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

		var displayRadius = DISPLAY_RADIUS * Player.LocalScale.x;

		if ( _isReady )
		{
			if ( Player.IsMoving )
			{
				_stillTimer = 0f;
			}
			else
			{
				_stillTimer += dt;
				if ( _stillTimer > STILL_TIME )
				{
					Shoot();
					_stillTimer = 0f;
				}
			}

			float progress = Utils.Map( _stillTimer, 0f, STILL_TIME, 0f, 1f );
			_particleRenderer.Scale = progress * displayRadius;
			_particleEffect.Alpha = Utils.Map( progress, 0f, 1f, 0f, 1f, EasingType.QuadOut );
		}
		else
		{
			_cooldownTimer += dt;
			if ( _cooldownTimer > GetValue( Level, Mod.Cooldown ) )
			{
				_isReady = true;
				Highlight();
			}

			_particleRenderer.Scale = 0f;
			_particleEffect.Alpha = 0f;
		}

		float bgTargetOpacity = !_isReady ? 0f : (Player.IsMoving ? 0.1f : 0.4f);
		_bgCurrOpacity = Utils.DynamicEaseTo( _bgCurrOpacity, bgTargetOpacity, 0.2f, dt );

		_particleRendererBg.Scale = displayRadius;
		_particleEffectBg.Alpha = _bgCurrOpacity;

		DisplayText = _isReady ? " " : $"{MathX.CeilToInt( GetValue( Level, Mod.Cooldown ) - _cooldownTimer )}";
		DisplayCooldown = _isReady ? 0f : Utils.Map( _cooldownTimer, 0f, GetValue( Level, Mod.Cooldown ), 1f, 0f );

		//Utils.DrawCircle(Player.Position2D, GetRadius(visual: false), 20, 0f, Color.Blue);
	}

	void Shoot()
	{
		_isReady = false;
		_cooldownTimer = 0f;

		Manager.Instance.SpawnRingRpc( Player.Position2D, GetRadius(visual: true), new Color( 0.3f, 1f, 0.3f, 0.3f ), lifetime: 0.35f, path: "ring2" );

		Manager.Instance.PlaySfxNearbyRpc( "heal", Player.Position2D, pitch: Game.Random.Float( 1f, 1.1f ), volume: 1.1f, maxDist: 400f );

		Player.Heal( amount: GetValue( Level, Mod.HpAmount ) );

		var pos = Player.Position2D;
		var radius = GetRadius( visual: false );

		var traceResults = Player.Scene.Trace.Sphere( radius, pos, pos ).WithAnyTags( "player" ).HitTriggersOnly().RunAll().ToList();
		foreach ( var tr in traceResults )
		{ 
			var gameObject = tr.GameObject;
			var player = gameObject.GetComponent<Player>();
			if ( player.IsDead || !(player.HpPercent < 1f) || player == Player )
				continue;

			player.HealRpc( amount: GetValue( Level, Mod.HpAmount ), otherPlayerHealer: Player );
		}
	}

	float GetRadius( bool visual = false )
	{
		return HEAL_RADIUS * Player.Stats[PlayerStat.RadiusMultiplier] * (visual ? 1.1f : 1f);
	}

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

		_particleEffect.Alpha = 0f;
		_particleEffectBg.Alpha = 0f;
	}

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

		if ( _particleEffect != null )
			_particleEffect.Destroy();

		if ( _particleEffectBg != null )
			_particleEffectBg.Destroy();
	}
}