unitStatus/UnitStatusFire.cs

A UnitStatus subclass that implements a burning/fire status for units. It tracks damage over time, optional stacking burn sources, applies periodic damage to the affected unit and to other units on collision, and can spread ignition to nearby units.

Networking
using Sandbox;
using Sandbox.UI;
using System;
using System.Numerics;

public struct StackBurnData
{
	public float damage;
	public float time;
	public float lifetime;

	public StackBurnData( float _damage, float _time, float _lifetime )
	{
		damage = _damage;
		time = _time;
		lifetime = _lifetime;
	}
}

public class UnitStatusFire : UnitStatus
{
	private TimeSince _timeSinceDamage;
	private const float DAMAGE_INTERVAL = 0.95f;

	public float SpreadChance { get; set; }
	public bool CanStack { get; set; }

	public Enemy EnemySource { get; set; }
	public EnemyType EnemyType { get; set; }
	public Player PlayerSource { get; set; }

	public float Damage { get; private set; }
	public float BaseDamage { get; private set; }

	private TimeSince _damageOtherTime;

	public bool StackDmgMode { get; set; }
	private Queue<StackBurnData> _stackTimes = new();

	public UnitStatusFire()
	{

	}

	public override void Init( Unit unit )
	{
		base.Init( unit );

		Unit.SetStatusBurning( true );

		_timeSinceDamage = 0f;
	}

	public void SetStartingDamage( float damage )
	{
		BaseDamage = damage;
		Damage = damage;
	}

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

		if ( !Unit.IsValid() || Manager.Instance.IsGameOver )
			return;

		//Gizmo.Draw.Color = Color.Red;
		//Gizmo.Draw.Text( $"{Damage}\n{BaseDamage}\n{_stackTimes.Count()}", new global::Transform( Unit.WorldPosition ) );

		if ( _stackTimes.Count > 0 )
		{
			var data = _stackTimes.First();

			//DebugOverlay.Text($"{Damage}\n{string.Format("{0:0.0}", Time.Now - data.time)} / {string.Format("{0:0.0}", data.lifetime)}", Unit.Position, 0f, float.MaxValue);

			if ( Time.Now > data.time + data.lifetime )
			{
				_stackTimes.Dequeue();
				RefreshStackDmg();

				ElapsedTime = 0f;
			}
		}
		else
		{
			if ( ElapsedTime > Lifetime )
				Unit.RemoveUnitStatus( this );

			//DebugOverlay.Text($"{Damage}\n{string.Format("{0:0.0}", ElapsedTime)} / {string.Format("{0:0.0}", Lifetime)}", Unit.Position, 0f, float.MaxValue);
		}

		if ( _timeSinceDamage > DAMAGE_INTERVAL )
		{
			if( IsOnEnemy )
			{
				if ( Enemy.IsValid() )
					Enemy.DamageRpc( Damage, PlayerSource, DamageType.Fire, Enemy.WorldPosition.WithZ( 30f ), Vector2.Zero, isCrit: false, shouldFlinch: false );
			}
			else
			{
				if( Player.IsValid() )
				{
					var isSelfInflicted = PlayerSource.IsValid() && PlayerSource == Player;

					var damageFlags = PlayerDamageFlags.None;
					if ( isSelfInflicted )
						damageFlags |= PlayerDamageFlags.SelfInflicted;

					Player.Damage( Damage, damageType: DamageType.Fire, Player.Position2D, Utils.GetRandomVector(), upwardAmount: 0f, force: 0f, ragdollForce: 1f, EnemySource, EnemyType, damageFlags: damageFlags );
				}
			}

			_timeSinceDamage = 0f;
		}
	}

	public void AddDamageStack( float damage, float lifetime )
	{
		_stackTimes.Enqueue( new StackBurnData( damage, Time.Now, lifetime ) );
		RefreshStackDmg();
	}

	void RefreshStackDmg()
	{
		Damage = BaseDamage;

		foreach ( var data in _stackTimes )
			Damage += data.damage;

		//Damage = PlayerSource.Stats[PlayerStat.FireDamage];

		//foreach ( var data in _stackTimes )
		//	Damage += data.damage;

		//Damage *= PlayerSource.GetDamageMultiplier();

		// todo: change particle appearance as stacks increase
	}

	public override void OnRemove( bool playEffects = true )
	{
		Unit.SetStatusBurning( false );
	}

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

		ElapsedTime = 0f;
	}

	public override void Colliding( Thing other, float percent, float dt )
	{
		base.Colliding( other, percent, dt );

		if ( Manager.Instance.IsGameOver )
			return;

		if ( _damageOtherTime > DAMAGE_INTERVAL )
		{
			if ( other is Unit unit && !unit.IsDying && !unit.IsBurning && !unit.IsInTheAir )
			{
				Vector2 dir = (unit.Position2D - Unit.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
					? (unit.Position2D - Unit.Position2D).Normal
					: Utils.GetRandomVector();

				var hitPos = unit.Position2D - dir * unit.Radius;

				if ( unit is Enemy enemy )
				{
					enemy.DamageRpc( Damage, PlayerSource, DamageType.Fire, new Vector3( hitPos.x, hitPos.y, 30f ), Vector2.Zero, isCrit: false, shouldFlinch: false );
				}
				else if ( unit is Player player )
				{
					var isSelfInflicted = PlayerSource.IsValid() && PlayerSource == player;

					var damageFlags = PlayerDamageFlags.None;
					if ( isSelfInflicted )
						damageFlags |= PlayerDamageFlags.SelfInflicted;

					player.DamageRpc( Damage, damageType: DamageType.Fire, hitPos, dir, upwardAmount: 0f, force: 0f, ragdollForce: 0.1f, EnemySource, EnemyType, damageFlags: damageFlags );
				}

				if ( Game.Random.Float( 0f, 1f ) < SpreadChance )
					unit.Ignite( PlayerSource, EnemySource, EnemyType, Damage, Lifetime, SpreadChance, CanStack );

				_damageOtherTime = 0f;
			}
		}
	}
}