things/Bullet.cs
using static Manager;

public enum BulletStat
{
	Damage, Force, AddTempWeight, Lifetime, NumPiercing, CriticalChance, CriticalMultiplier, BulletSpread, BulletInaccuracy, BulletSpeed, BulletLifetime,
	GrowDamageAmount, ShrinkDamageAmount, DistanceDamageAmount, HealTeammateAmount,
	WillIgnite, WillFreeze,
	NumBouncing,
}

public class Bullet : Thing
{
	[Property] Sprite HomingSprite { get; set; }

	public TimeSince TimeSinceSpawn { get; private set; }
	public Player Shooter { get; set; }

	public Dictionary<BulletStat, float> Stats { get; private set; }

	public List<Thing> _hitThings = new List<Thing>();
	//private float _scaleFactor;

	public bool IsHoming { get; set; }
	private TimeSince _timeSinceHoming;
	private float _nextHomingTime;
	private int _numHomingsToClearHit;
	private const float HOMING_DELAY_MIN = 0.05f;
	private const float HOMING_DELAY_MAX = 0.2f;

	private float _startingSpriteY;

	protected override void OnAwake()
	{
		base.OnAwake();

		//OffsetY = -0.45f;
		//Sprite.LocalPosition = new Vector3( 0f, -OffsetY, 0f );

		Sprite.LocalPosition = new Vector3( 0f, 0.45f, 0f );

		Radius = 0.1f;

		ShadowOpacity = 0.8f;
		ShadowScale = 0.3f;
		SpawnShadow( ShadowScale, ShadowOpacity );
		//Log.Info( $"ShadowScale: {ShadowScale} ShadowSprite.LocalScale: {ShadowSprite.LocalScale}" );

		if ( IsProxy )
			return;

		//Scale = new Vector2( 0.1f, 0.1f );
		TimeSinceSpawn = 0f;

		Stats = new Dictionary<BulletStat, float>();

		Stats[BulletStat.Damage] = 1f;
		Stats[BulletStat.AddTempWeight] = 2f;
		Stats[BulletStat.Force] = 0.75f;
		Stats[BulletStat.Lifetime] = 1f;
		Stats[BulletStat.NumPiercing] = 0;
		Stats[BulletStat.CriticalChance] = 0;
		Stats[BulletStat.CriticalMultiplier] = 1f;
		Stats[BulletStat.HealTeammateAmount] = 0f;

		CollideWith.Add( typeof( Enemy ) );

		_timeSinceHoming = 0f;
		_nextHomingTime = Game.Random.Float( HOMING_DELAY_MIN, HOMING_DELAY_MAX );

		_startingSpriteY = Sprite.LocalPosition.y;
	}

	public void Init()
	{
		if ( IsHoming )
		{
			Sprite.Sprite = HomingSprite;
			FaceDirection( Velocity.Normal );
		}

		//_scaleFactor = Utils.Map( Stats[BulletStat.Damage], 10f, 120f, 0.015f, 0.003f, EasingType.Linear );
		DetermineSize();

		//if ( Stats[BulletStat.HealTeammateAmount] > 0f )
		//{
		//	CollideWith.Add( typeof( Player ) );
		//	Sprite.Tint = Color.Green;
		//}

		Color color = Color.White;

		if ( Stats[BulletStat.WillIgnite] > 0f && Stats[BulletStat.WillFreeze] > 0f )
			color = new Color( 0.9f, 0f, 1f );
		else if ( Stats[BulletStat.WillIgnite] > 0f )
			color = new Color( 1f, 0.06f, 0.06f );
		else if ( Stats[BulletStat.WillFreeze] > 0f )
			color = new Color( 0.1f, 0.1f, 1f );

		Sprite.Tint = color;
	}

	void FaceDirection( Vector2 dir )
	{
		Sprite.LocalRotation = new Angles( 0f, Utils.VectorToDegrees( -dir ), 0f );
	}

	void DetermineSize()
	{
		var damage = Stats[BulletStat.Damage];
		//float scale = 0.125f + damage * _scaleFactor;
		float scale = damage < 30f
			? Utils.Map( damage, 0f, 30f, 0.1f, 0.5f, EasingType.QuadOut )
			: Utils.Map( damage, 30f, 150f, 0.5f, 1.75f, EasingType.QuadIn );

		if ( IsHoming )
			scale *= 1.15f;

		Scale = scale;
		//Sprite.Size = new Vector2( scale );
		Sprite.LocalScale = new Vector3( 1f ) * scale * Globals.SPRITE_SCALE;

		Radius = 0.07f + scale * 0.2f;
		ShadowScale = scale * 1.3f;

		ShadowSpriteDirty = true;
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		//Log.Info( $"Stats: {Stats}" );

		if ( !IsProxy )
		{
			//Gizmo.Draw.Color = Color.White;
			//Gizmo.Draw.Text( $"TimeSinceSpawn: {TimeSinceSpawn}\nStats[BulletStat.Damage]: {Stats[BulletStat.Damage]}\nStats[BulletStat.Lifetime]: {Stats[BulletStat.Lifetime]}", new global::Transform( (Vector3)Position2D + new Vector3( 0f, -0.2f, 0f ) ) );
		}

		//Gizmo.Draw.Color = Color.White.WithAlpha( 0.05f );
		//Gizmo.Draw.LineSphere( (Vector3)Position2D, Radius );

		if ( !Manager.Instance.ShouldUpdateThings )
			return;

		if ( IsProxy )
			return;

		//if ( Shooter == null || Shooter.IsDead )
		//{
		//	Remove();
		//	return;
		//}

		float dt = Time.Delta;

		Position2D += Velocity * dt;
		WorldPosition = WorldPosition.WithZ( Globals.GetZPos( Position2D.y ) );

		bool changedDamage = false;

		if ( Stats[BulletStat.GrowDamageAmount] > 0f )
		{
			Stats[BulletStat.Damage] += Stats[BulletStat.GrowDamageAmount] * dt;
			changedDamage = true;
		}

		if ( Stats[BulletStat.ShrinkDamageAmount] > 0f )
		{
			Stats[BulletStat.Damage] -= Stats[BulletStat.ShrinkDamageAmount] * dt;
			changedDamage = true;

			if ( Stats[BulletStat.Damage] <= 0f )
			{
				Remove();
				return;
			}
		}

		if ( Stats[BulletStat.DistanceDamageAmount] > 0f )
		{
			Stats[BulletStat.Damage] += Stats[BulletStat.DistanceDamageAmount] * Velocity.Length * dt;
			changedDamage = true;
		}

		if ( changedDamage )
			DetermineSize();

		if ( TimeSinceSpawn > Stats[BulletStat.Lifetime] )
		{
			var cloud = Manager.Instance.SpawnCloud( Position2D );
			cloud.Lifetime = Game.Random.Float( 0.15f, 0.3f );
			cloud.Velocity = Velocity * Game.Random.Float( 0f, 1f );
			cloud.Deceleration = Game.Random.Float( 20f, 50f );
			cloud.LocalScale *= Game.Random.Float( 0.4f, 0.75f ) * Utils.Map( Scale, 0.175f, 0.5f, 0.9f, 2f );

			Remove();
			return;
		}

		float progress = Utils.Map( TimeSinceSpawn, 0f, Stats[BulletStat.Lifetime], 0f, 1f, EasingType.QuadIn );
		Sprite.LocalPosition = Sprite.LocalPosition.WithY( Utils.Map( progress, 0f, 1f, _startingSpriteY, Radius * 0.6f ) );
		float shadowSize = Utils.Map( progress, 0f, 1f, Scale * 1.3f, Scale * 1.5f );
		ShadowSprite.LocalScale = new Vector3( shadowSize * Globals.SPRITE_SCALE, 1f, shadowSize * Globals.SPRITE_SCALE );
		ShadowSprite.Tint = Color.Black.WithAlpha( Utils.Map( progress, 0f, 1f, 0.5f, 1f ) );

		for ( int dx = -1; dx <= 1; dx++ )
		{
			for ( int dy = -1; dy <= 1; dy++ )
			{
				Manager.Instance.HandleThingCollisionForGridSquare( this, new GridSquare( GridPos.x + dx, GridPos.y + dy ), dt );

				if ( IsRemoved )
					return;
			}
		}

		if ( IsHoming && _timeSinceHoming > _nextHomingTime )
			ActivateHoming();

		if ( Stats[BulletStat.NumBouncing] > 0f )
			CheckBoundsForBounce();
	}

	void CheckBoundsForBounce()
	{
		var xMin = Manager.Instance.BOUNDS_MIN.x;
		var yMin = Manager.Instance.BOUNDS_MIN.y;
		var xMax = Manager.Instance.BOUNDS_MAX.x;
		var yMax = Manager.Instance.BOUNDS_MAX.y;
		var x = Position2D.x;
		var y = Position2D.y;

		bool didHit = false;
		if ( x < xMin )
		{
			Position2D = new Vector2( xMin, y );
			Velocity = new Vector2( -Velocity.x, Velocity.y );
			didHit = true;
		}
		else if ( x > xMax )
		{
			Position2D = new Vector2( xMax, y );
			Velocity = new Vector2( -Velocity.x, Velocity.y );
			didHit = true;
		}

		if ( y < yMin )
		{
			Position2D = new Vector2( x, yMin );
			Velocity = new Vector2( Velocity.x, -Velocity.y );
			didHit = true;
		}
		else if ( y > yMax )
		{
			Position2D = new Vector2( x, yMax );
			Velocity = new Vector2( Velocity.x, -Velocity.y );
			didHit = true;
		}

		if ( didHit )
			BounceBounds();
	}

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

		if ( Shooter == null )
			return;

		if ( other is Enemy enemy )
		{
			bool ignoreCharmedEnemies = true; // todo: upgrade that reduces dmg to charmed enemies, final level ignores collision
			if ( enemy.IsDying || (enemy.IsSpawning && enemy.TimeSinceSpawn < 1.5f) || enemy.IgnoreCollision || (enemy.IsCharmed && ignoreCharmedEnemies) )
				return;

			if ( _hitThings.Contains( enemy ) )
				return;

			if ( Stats[BulletStat.WillIgnite] > 0f )
			{
				var burning = enemy.GetEnemyStatus<BurningEnemyStatus>();
				if ( burning != null )
				{
					burning.AddStack( Shooter.Stats[PlayerStat.FireDamage], Shooter.Stats[PlayerStat.FireLifetime], Shooter.Stats[PlayerStat.FireSpreadChance] );
				}
				else
				{
					enemy.Burn( Shooter, Shooter.Stats[PlayerStat.FireDamage], Shooter.Stats[PlayerStat.FireLifetime], Shooter.Stats[PlayerStat.FireSpreadChance] );
					Manager.Instance.PlaySfxNearby( "burn", Position2D, pitch: Sandbox.Game.Random.Float( 0.95f, 1.05f ), volume: 0.85f, maxDist: 5f );
				}

				enemy.TimeSinceBurn = 0f;
			}

			if ( Stats[BulletStat.WillFreeze] > 0f )
			{
				if ( !enemy.HasEnemyStatus<FrozenEnemyStatus>() )
					Manager.Instance.PlaySfxNearby( "frozen_02", Position2D, pitch: Game.Random.Float( 1.3f, 1.4f ), volume: 1.1f, maxDist: 3f );

				enemy.Freeze( Shooter );
			}

			bool isCrit = Game.Random.Float( 0f, 1f ) < Stats[BulletStat.CriticalChance];
			float damage = Stats[BulletStat.Damage] * (isCrit ? Stats[BulletStat.CriticalMultiplier] : 1f);
			var addVel = Velocity.Normal * Stats[BulletStat.Force] * (8f / enemy.PushStrength);// * (Math.Min(damage, 5f) / 5f);
			enemy.Damage( damage, Shooter, addVel, Stats[BulletStat.AddTempWeight], isCrit, DamageType.PlayerBullet );

			//if(isCrit)
			//	Manager.Instance.PlaySfxNearby( "crit_0", Position2D, pitch: Utils.Map( enemy.Health, enemy.MaxHealth, 0f, 1.3f, 1.5f, EasingType.SineIn ), volume: 1f, maxDist: 4f );
			//else
			Manager.Instance.PlaySfxNearby( "enemy.hit", Position2D, pitch: Utils.Map( enemy.Health, enemy.MaxHealth, 0f, 0.9f, 1.3f, EasingType.SineIn ), volume: 1f, maxDist: 4f );

			//enemy.Charm();

			int numPierces = (int)Stats[BulletStat.NumPiercing];
			int numBounces = (int)Stats[BulletStat.NumBouncing];

			if ( numPierces > 0 )
			{
				Pierce( enemy );
			}
			else if ( numBounces > 0 )
			{
				Bounce( enemy );
			}
			else
			{
				Remove();
				return;
			}
		}
		//else if ( other is Player player && player != Shooter && !player.IsDead )
		//{
		//	if ( _hitThings.Contains( player ) )
		//		return;

		//	player.Heal( Stats[BulletStat.HealTeammateAmount], 0.05f );

		//	NumHits++;

		//	if ( NumHits > (int)Stats[BulletStat.NumPiercing] )
		//	{
		//		Remove();
		//		return;
		//	}
		//	else
		//	{
		//		_hitThings.Add( player );
		//	}
		//}
	}

	void Pierce( Enemy enemy )
	{
		_hitThings.Add( enemy );

		if ( IsHoming )
		{
			_timeSinceHoming = 0f;
			_nextHomingTime = Game.Random.Float( HOMING_DELAY_MIN, HOMING_DELAY_MAX );
			_numHomingsToClearHit = 3;
		}

		Stats[BulletStat.NumPiercing] -= 1f;
	}

	public void Bounce( Enemy enemy )
	{
		Vector2 dir = (Position2D - enemy.Position2D).LengthSquared > 0.01f
				? (Position2D - enemy.Position2D).Normal
				: Utils.GetRandomVector();

		Velocity = dir * Velocity.Length;

		FaceDirection( dir );

		Stats[BulletStat.NumBouncing] -= 1f;
		_hitThings.Clear();
		_hitThings.Add( enemy );

		if ( IsHoming )
		{
			_timeSinceHoming = 0f;
			_nextHomingTime = Game.Random.Float( HOMING_DELAY_MIN, HOMING_DELAY_MAX );
			_numHomingsToClearHit = 3;
		}
	}

	void BounceBounds()
	{
		Vector2 dir = Velocity.Normal;

		FaceDirection( dir );

		Stats[BulletStat.NumBouncing] -= 1f;
		_hitThings.Clear();

		Manager.Instance.PlaySfxNearby( "enemy.hit", Position2D, pitch: Game.Random.Float( 1.55f, 1.6f ), volume: 0.7f, maxDist: 3f );
	}

	void ActivateHoming()
	{
		_numHomingsToClearHit--;
		if ( _numHomingsToClearHit == 0 )
			_hitThings.Clear();

		Enemy closestEnemy = null;
		float closestDistSqr = float.MaxValue;
		for ( int dx = -1; dx <= 1; dx++ )
		{
			for ( int dy = -1; dy <= 1; dy++ )
			{
				Enemy enemy;
				GetClosestEnemyInGridSquare( new GridSquare( GridPos.x + dx, GridPos.y + dy ), out enemy );

				if ( enemy != null && !_hitThings.Contains( enemy ) )
				{
					float distSqr = (enemy.Position2D - Position2D).LengthSquared;
					if ( distSqr < closestDistSqr )
					{
						closestEnemy = enemy;
						closestDistSqr = distSqr;
					}
				}
			}
		}

		if ( closestEnemy != null )
		{
			var dir = (closestEnemy.Position2D - Position2D).Normal;
			Velocity = dir * Velocity.Length;

			FaceDirection( dir );
		}

		_timeSinceHoming = 0f;
		_nextHomingTime = Game.Random.Float( HOMING_DELAY_MIN, HOMING_DELAY_MAX );
	}

	public void GetClosestEnemyInGridSquare( GridSquare gridSquare, out Enemy closestEnemy )
	{
		closestEnemy = null;
		float closestDistSqr = float.MaxValue;

		if ( !Manager.Instance.ThingGridPositions.ContainsKey( gridSquare ) )
			return;

		var things = Manager.Instance.ThingGridPositions[gridSquare];
		if ( things.Count == 0 )
			return;

		for ( int i = things.Count - 1; i >= 0; i-- )
		{
			if ( i >= things.Count )
				continue;

			var other = things[i];
			if ( other == this || other.IsRemoved || !other.IsValid() )
				continue;

			if ( other is not Enemy enemy || enemy.IsDying || enemy.IsSpawning || enemy.IgnoreCollision || enemy.IsCharmed )
				continue;

			var dist_sqr = (Position2D - enemy.Position2D).LengthSquared;
			if ( dist_sqr < closestDistSqr )
			{
				closestDistSqr = dist_sqr;
				closestEnemy = enemy;
			}
		}
	}
}