things/enemies/Exploder.cs
using Sandbox;
using static Manager;

public class Exploder : Enemy
{
	private TimeSince _damageTime;
	private const float DAMAGE_TIME = 0.75f;

	private const float EXPLOSION_RADIUS = 1.45f;
	public float ExplosionDamage { get; set; }
	private const float EXPLODE_TIME = 1.5f;

	[Sync] public bool IsExploding { get; set; }
	private TimeSince _explodeStartTime;
	private bool _hasExploded;

	private Player _playerWhoKilledUsId;

	public override float HeightVariance => 0.03f;
	public override float WidthVariance => 0.015f;

	public float MoveSpeed { get; set; }
	public float MoveSpeedAttacking { get; set; }

	protected override void OnAwake()
	{
		ShadowScale = 1.05f;
		ShadowFullOpacity = 0.8f;
		ShadowOpacity = 0f;

		Scale = 1.1f;

		base.OnAwake();

		PushStrength = 12f;
		Deceleration = 1.87f;
		DecelerationAttacking = 1.53f;

		Radius = 0.24f;

		Health = 40f;

		if ( Manager.Instance.Difficulty < 0 )
			Health = 32f;

		MaxHealth = Health;
		DamageToPlayer = 12f;

		DeathTime = 0.2f;

		CoinValueMin = 2;
		CoinValueMax = 3;
		CoinChance = 0.6f;

		if ( IsProxy )
			return;

		CollideWith.Add( typeof( Enemy ) );
		CollideWith.Add( typeof( Player ) );

		_damageTime = DAMAGE_TIME;

		MoveSpeed = 0.7f;
		MoveSpeedAttacking = 1.3f;

		ExplosionDamage = 40f;
	}

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

		if ( !Manager.Instance.ShouldUpdateThings )
			return;

		if ( IsExploding )
			Sprite.FlashTint = Color.Yellow.WithAlpha( (0.5f + Utils.FastSin( Time.Now * 32f ) * 0.5f) * Utils.Map( _explodeStartTime, 0.5f, EXPLODE_TIME, 0f, 0.75f, EasingType.QuadIn ) );

		if ( IsExploding )
		{
			if ( !IsProxy && !_hasExploded && _explodeStartTime > EXPLODE_TIME )
				Explode();
		}
	}


	protected override void UpdateSprite( Thing target )
	{
		if ( IsExploding )
		{
			if ( _explodeStartTime > 0.5f )
			{
				Sprite.PlayAnimation( "explode_loop" );
			}
			else
			{
				Sprite.PlayAnimation( "explode_start" );
			}
		}
		else
		{
			base.UpdateSprite( target );
		}
	}

	protected override void UpdatePosition( float dt )
	{
		base.UpdatePosition( dt );

		if ( !IsExploding )
		{
			var targetPos = GetTargetPos();
			Velocity += (targetPos - Position2D).Normal * 1.0f * dt * (IsFeared ? -1f : 1f);

			float speed = ( IsAttacking ? MoveSpeedAttacking : MoveSpeed) + Utils.FastSin( MoveTimeOffset + Time.Now * (IsAttacking ? 15f : 7.5f) ) * (IsAttacking ? 0.3f : 0.2f);

			if ( Manager.Instance.Difficulty < 0 )
				speed *= 0.85f;

			WorldPosition += (Vector3)Velocity * speed * dt;
		}
		else
		{
			WorldPosition += (Vector3)Velocity * 0.2f * dt;
		}
	}

	protected virtual Vector2 GetTargetPos()
	{
		return Target.IsValid() ? Target.Position2D : (IsCharmed ? Manager.Instance.Player.Position2D : Position2D);
	}

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

		if ( other is Enemy enemy && !enemy.IsDying )
		{
			var spawnFactor = Utils.Map( enemy.TimeSinceSpawn, 0f, enemy.SpawnTime, 0f, 1f, EasingType.QuadIn );
			Velocity += (Position2D - enemy.Position2D).Normal * Utils.Map( percent, 0f, 1f, 0f, 1f ) * enemy.PushStrength * (1f + enemy.TempWeight) * spawnFactor * dt;

			if ( IsAttacking && IsCharmed != enemy.IsCharmed && _damageTime > (DAMAGE_TIME / TimeScale) )
			{
				var dmg = DamageToPlayer;
				if ( IsCharmed )
					dmg *= CharmDamageDealtMultiplier;

				enemy.Damage( dmg, null, addVel: Vector2.Zero, addTempWeight: 0f, isCrit: false, DamageType.Melee );
				enemy.Target = this;
				_damageTime = 0f;
				Manager.Instance.PlaySfxNearby( "zombie.attack.player", Position2D, pitch: Utils.Map( enemy.Health, enemy.MaxHealth, 0f, 0.95f, 1.15f, EasingType.QuadIn ), volume: 0.6f, maxDist: 4.5f );
			}
		}
		// todo: move collision check to player instead to prevent laggy hits?
		else if ( other is Player player )
		{
			if ( !player.IsDead )
			{
				Velocity += (Position2D - player.Position2D).Normal * Utils.Map( percent, 0f, 1f, 0f, 1f ) * player.Stats[PlayerStat.PushStrength] * (1f + player.TempWeight) * dt;

				if ( IsAttacking && _damageTime > (DAMAGE_TIME / TimeScale) && !IsCharmed )
				{
					float dmg = player.CheckDamageAmount( DamageToPlayer, DamageType.Melee );

					if ( !player.IsInvulnerable && !player.IsTimePausedForChoosing )
					{
						Manager.Instance.PlaySfxNearby( "zombie.attack.player", Position2D, pitch: Utils.Map( player.Health, player.Stats[PlayerStat.MaxHp], 0f, 0.95f, 1.15f, EasingType.QuadIn ), volume: 1f, maxDist: 5.5f );

						player.Damage( dmg );

						if ( dmg > 0f )
							OnDamagePlayer( player, dmg );
					}

					_damageTime = 0f;
				}
			}
		}
	}

	public override void StartDying( Player player )
	{
		if ( !IsExploding )
		{
			StartExploding();
			_playerWhoKilledUsId = player;
		}
	}

	public void StartExploding()
	{
		IsExploding = true;
		_explodeStartTime = 0f;
		Sprite.PlayAnimation( "explode_start" );
		CanAttack = false;
		CanAttackAnim = false;
		CanTurn = false;
	}

	public virtual void Explode()
	{
		Manager.Instance.SpawnExplosionEffect( Position2D, Color.Yellow, Color.Red );
		Manager.Instance.PlaySfxNearby( "explode", Position2D, pitch: Game.Random.Float( 0.9f, 1.1f ), volume: 0.9f, maxDist: 6f );

		_hasExploded = true;
		IsExploding = false;

		if ( IsProxy )
			return;

		base.StartDying( _playerWhoKilledUsId );
	}

	public override void FinishDying()
	{
		if( Manager.Instance.Difficulty >= 1 && Game.Random.Float(0f, 1f) < 0.5f )
		{
			int numBlobs = (int)MathF.Floor( Game.Random.Float( 1f, (float)Math.Min(Manager.Instance.Difficulty, 5)) );

			if ( numBlobs > 3 && Game.Random.Float( 0f, 1f ) < 0.5f )
				numBlobs--;

			bool spawnedBlob = false;

			for (int i = 0; i < numBlobs; i++)
			{
				if(Manager.Instance.GetLavaBlobEndPos( Position2D, out Vector2 endPos ) )
				{
					Manager.Instance.SpawnLavaBlob( Position2D, endPos );
					spawnedBlob = true;
				}
			}

			if ( spawnedBlob )
			{
				Manager.Instance.PlaySfxNearby( "lava_blob_01", Position2D, pitch: Game.Random.Float( 0.9f, 1.1f ), volume: 1.4f, maxDist: 6f );
			}
		}

		List<Thing> nearbyThings = new List<Thing>();

		for ( int dx = -2; dx <= 2; dx++ )
			for ( int dy = -2; dy <= 2; dy++ )
				Manager.Instance.AddThingsInGridSquare( new GridSquare( GridPos.x + dx, GridPos.y + dy ), nearbyThings );

		var dmgToDeal = ExplosionDamage;
		if ( IsCharmed )
			dmgToDeal *= CharmDamageDealtMultiplier;

		foreach ( Thing thing in nearbyThings )
		{
			if ( !thing.IsValid() || thing == this )
				continue;

			if ( thing is Enemy enemy)
			{
				if( !enemy.IsDying && (!enemy.IsSpawning || enemy.TimeSinceSpawn > 0.75f) && !enemy.IgnoreCollision )
				{
					var dist_sqr = (thing.Position2D - Position2D).LengthSquared;
					if ( dist_sqr < MathF.Pow( EXPLOSION_RADIUS, 2f ) )
					{
						float force = ExplosionDamage * Utils.Map( dist_sqr, 0f, MathF.Pow( EXPLOSION_RADIUS, 2f ), 0.2f, 0f, EasingType.QuadIn );
						if ( enemy is ExploderElite )
							force *= 0.2f;

						var addVel = (thing.Position2D - Position2D).Normal * force;
						var addTempWeight = 0f;

						enemy.Damage( dmgToDeal, null, addVel, addTempWeight, false );
					}
				}
			}
			else if ( thing is Player player ) 
			{
				if ( !player.IsDead && !player.IsInvulnerable && !player.IsTimePausedForChoosing )
				{
					var dist_sqr = (thing.Position2D - Position2D).LengthSquared;
					if ( dist_sqr < MathF.Pow( EXPLOSION_RADIUS, 2f ) * 0.95f )
					{
						var dmg = player.CheckDamageAmount( dmgToDeal, DamageType.Explosion );
						player.Damage( dmg );
						player.AddVelocity( (thing.Position2D - Position2D).Normal * dmg * Utils.Map( dist_sqr, 0f, MathF.Pow( EXPLOSION_RADIUS, 2f ) * 0.95f, 0.2f, 0f, EasingType.QuadIn ) );
					}
				}
			}
			else if (thing is Coin || thing is Magnet || thing is HealthPack || thing is RerollPickup )
			{
				var dist_sqr = (thing.Position2D - Position2D).LengthSquared;
				if ( dist_sqr < MathF.Pow( EXPLOSION_RADIUS, 2f ) * 0.95f )
				{
					thing.Velocity += (thing.Position2D - Position2D).Normal * 2.5f * Utils.Map( dist_sqr, 0f, MathF.Pow( EXPLOSION_RADIUS, 2f ) * 0.95f, 1f, 0f, EasingType.QuadIn );
				}
			}
		}

		base.FinishDying();
	}

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

		CelebrateAsync();
	}

	async void CelebrateAsync()
	{
		await Task.Delay( Game.Random.Int( 0, 500 ) );

		Sprite.PlaybackSpeed = Game.Random.Float( 1.5f, 3f );

		Sprite.PlayAnimation( "cheer_start" );

		await Task.Delay( Game.Random.Int( 200, 400 ) );

		Sprite.PlayAnimation( "cheer" );
	}
}