perks/PerkRadiation.cs

A Perk component (PerkRadiation) that periodically damages nearby enemies, can heal nearby players, and may damage the owner. It spawns and controls a particle GameObject, computes pulse hits via sphere traces, applies damage/heal/repel, and updates visual timing and stats.

NetworkingFile Access
using System;
using Sandbox;

[Perk( Rarity.Rare, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.Aoe, PerkCategory.Radiation, PerkCategory.SelfDmg, PerkCategory.OneHpLeft })]
public class PerkRadiation : Perk
{
	public override float ImportanceMultiplier => 1.15f;

	private enum Mod { Damage, SelfDamageChance };

	private float _timer;
	private const float DAMAGE_RADIUS = 130f;

	//private Particles _particles;

	private GameObject _radiationGo; // destroyed on Restart because it has ParticleEffect
	private ParticleSpriteRenderer _particleRenderer;
	private RadiationParticleEffect _radiationParticleEffect;

	private float _baseDelay;
	private float _baseLifetime;
	private float _baseRate;


	static PerkRadiation()
	{
		Register<PerkRadiation>(
			name: "Radiation",
			imagePath: "textures/icons/vector/radiation.png",
			description: level => $"Periodically do [+]{ GetValue( level, Mod.Damage ).ToString( "0.#" )}[/+] dmg to nearby enemies, with [-]{GetValue( level, Mod.SelfDamageChance, true )}%[/-] chance to hurt yourself too (this dmg can't kill you)",
			upgradeDescription: level => $"Periodically do {GetValue( level - 1, Mod.Damage ).ToString( "0.#" )}→{GetValue( level, Mod.Damage ).ToString( "0.#" )} dmg to nearby enemies, with {GetValue( level - 1, Mod.SelfDamageChance, true )}%→{GetValue( level, Mod.SelfDamageChance, true )}% chance to hurt yourself too (this dmg can't kill you)",
			descriptionLineHeight: 10.5f
		);
	}

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

		ShouldUpdate = true;

		_radiationGo = GameObject.Clone( "prefabs/radiation.prefab", new CloneConfig { StartEnabled = true, Parent = Player.GameObject } );
		_radiationGo.LocalPosition = new Vector3( 0f, 0f, 5f );
		_radiationParticleEffect = _radiationGo.GetComponent<RadiationParticleEffect>();
		_particleRenderer = _radiationGo.GetComponent<ParticleSpriteRenderer>();

		_particleRenderer.Scale = GetRadius( visual: true );

		_baseDelay = Player.Stats[PlayerStat.RadiationDelay];
		_baseLifetime = _radiationParticleEffect.ParticleEffect.Lifetime.ConstantValue;
		_baseRate = _radiationParticleEffect.SphereEmitter.Rate.ConstantValue;

		_radiationGo.NetworkSpawn();
	}

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

		// todo: display radiation damage / delay on stats panel

		RefreshHpRegenDisplay();
	}

	void RefreshHpRegenDisplay()
	{
		var averageSelfDps = (GetValue( Level, Mod.Damage ) * GetValue( Level, Mod.SelfDamageChance )) / Player.Stats[PlayerStat.RadiationDelay];
		Player.Modify( this, PlayerStat.HpRegenDisplay, -averageSelfDps, ModifierType.Add );

		// todo: need to refresh if you get perk that lowers self-dmg

		// todo: decrease max hp over time instead?
	}

	private static float GetValue( int level, Mod mod, bool isPercent = false )
	{
		switch ( mod )
		{
			case Mod.Damage:
			default:
				return 1.1f + 0.6f * level;
			case Mod.SelfDamageChance:
				return isPercent
					? 29f - 2f * level
					: 0.29f - 0.02f * level;
		}
	}

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

		float delay = Player.Stats[PlayerStat.RadiationDelay];

		//Gizmo.Draw.Color = Color.Red.WithAlpha(0.1f);
		//Gizmo.Draw.LineSphere( Player.WorldPosition.WithZ( 50f ), GetRadius(), rings: 32 );

		_timer += dt;
		if ( _timer > delay )
		{
			Pulse();
			_timer = 0f;
		}

		DisplayCooldown = Utils.Map( _timer, 0f, delay, 0f, 1f );
	}

	void Pulse()
	{
		float damage = GetValue( Level, Mod.Damage );
		float heal = Player.Stats[PlayerStat.RadiationHealAmount];
		float repel = Player.Stats[PlayerStat.RadiationRepelAmount];
		var radius = GetRadius();
		var pos = Player.Position2D;

		var numHits = 0;
		var averagePos = Vector2.Zero;

		var traceResults = Player.Scene.Trace.Sphere( radius, pos, pos ).WithAnyTags( "enemy", "player" ).HitTriggersOnly().RunAll().ToList();
		foreach ( var tr in traceResults )
		{
			var gameObject = tr.GameObject;

			if( gameObject.Tags.Has("enemy"))
			{
				var enemy = gameObject.GetComponent<Enemy>();
				if ( !enemy.IsValid() || enemy.IsDying || enemy.IsInTheAir || (enemy.IsSpawning && !enemy.AlmostFinishedSpawning) )
					continue;

				Vector2 dir = (enemy.Position2D - pos).Normal;
				var hitPos = enemy.Position2D;

				if ( damage < 1f )
				{
					if ( Game.Random.Float( 0f, 1f ) < damage )
						damage = 1f;
					else
						continue;
				}

				enemy.DamageRpc( damage, Player, DamageType.Radiation, hitPos, force: dir * repel, isCrit: false, shouldFlinch: false );
				numHits++;
				averagePos += hitPos;
			}
			else if ( Player.Stats[PlayerStat.RadiationHealAmount] > 0f && gameObject.Tags.Has( "player" ) && gameObject != Player.GameObject )
			{
				var player = gameObject.GetComponent<Player>();
				if ( player.IsValid() && !player.IsDead && player.Health < player.GetSyncStat( PlayerStat.MaxHp ) )
				{
					player.HealRpc( heal, otherPlayerHealer: Player );
				}
			}
		}

		if( numHits > 0 )
			Manager.Instance.PlaySfxNearbyRpc( "enemy.hit", averagePos / numHits, pitch: Game.Random.Float( 0.9f, 1f ), volume: 0.5f, maxDist: 250f );

		if ( Game.Random.Float( 0f, 1f ) < GetValue( Level, Mod.SelfDamageChance ) )
		{
			Player.Damage( damage, DamageType.Self, Player.Position2D, Utils.GetRandomVector(), upwardAmount: 0f, force: 0f, ragdollForce: 0.2f, enemySource: null, enemyType: EnemyType.None, cantKill: true );
		}
	}

	float GetRadius( bool visual = false )
	{
		//return DAMAGE_RADIUS * Player.Stats[PlayerStat.RadiusMultiplier] * (Utils.MapReturn( _timer, 0f, Player.Stats[PlayerStat.RadiationDelay], 1f, 0.9f, EasingType.QuadInOut )) * (visual ? 1.4f : 1f);
		return DAMAGE_RADIUS * Player.Stats[PlayerStat.RadiusMultiplier] * (visual ? 2.5f : 1f);
	}

	public override void OnAddPerkAfter( TypeDescription type )
	{
		base.OnAddPerkAfter( type );

		if ( type.TargetType == typeof( PerkRadiusMultiplier ) )
		{
			_radiationParticleEffect.SetRadius( GetRadius( visual: true ) );
		}
		else if ( type.TargetType == typeof( PerkRadiationRepel) || type.TargetType == typeof( PerkRadiationDelay ) )
		{
			float change = Player.Stats[PlayerStat.RadiationDelay] / _baseDelay;

			_radiationParticleEffect.SetTiming( lifetime: _baseLifetime * change, rate: _baseRate / change );

			//RefreshHpRegenDisplay();
		}
		else if ( type.TargetType == typeof( PerkRadiationHeal ) )
		{
			_radiationParticleEffect.ShowHealing();
		}
	}

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

		_radiationParticleEffect.SetVisible( false );
	}

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

		_radiationParticleEffect.SetVisible( true );
	}

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

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