things/enemies/SpikerElite.cs
using Sandbox;
using System;

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

	private float _shootDelayTimer;
	private const float SHOOT_DELAY_MIN = 2f;
	private const float SHOOT_DELAY_MAX = 4.5f;

	public bool IsShooting { get; private set; }
	private float _shotTimer;
	private const float SHOOT_TIME = 4f;
	private bool _hasShot;
	private TimeSince _prepareStartTime;
	private bool _hasReversed;

	private bool _moveClockwise;
	public static int SpikerEliteNum { get; set; }
	private float _perpendicularMaxDist;

	private float _digDelayTimer;
	private const float DIG_DELAY_MIN = 4f;
	private const float DIG_DELAY_MAX = 13f;
	public bool IsDigging { get; private set; }
	private TimeSince _timeSinceStartDigging;
	private const float DIG_TIME = 1.2f;

	protected override void OnAwake()
	{
		//OffsetY = -0.82f;
		ShadowScale = 1.65f;
		ShadowFullOpacity = 0.8f;
		ShadowOpacity = 0f;

		Scale = 1.9f;

		base.OnAwake();

		//AnimSpeed = 3f;
		//Sprite.Texture = Texture.Load("textures/sprites/spiker_elite.vtex");

		//Sprite.Size = new Vector2( 1f, 1f ) * Scale;

		PushStrength = 12f;
		Deceleration = 2.57f;
		DecelerationAttacking = 2.35f;
		AggroRange = 0.45f;

		Radius = 0.28f;

		Health = 220f;

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

		MaxHealth = Health;
		DamageToPlayer = 16f;

		CoinValueMin = 7;
		CoinValueMax = 15;
		CoinChance = 0.85f;

		if ( IsProxy )
			return;

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

		_damageTime = DAMAGE_TIME;
		_shootDelayTimer = Game.Random.Float( SHOOT_DELAY_MIN, SHOOT_DELAY_MAX );
		_digDelayTimer = Game.Random.Float( DIG_DELAY_MIN, DIG_DELAY_MAX );

		_moveClockwise = SpikerEliteNum % 2 == 0;
		SpikerEliteNum++;
		_perpendicularMaxDist = Game.Random.Float( 4.5f, 7.5f );
	}

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

		var targetPos = Target.IsValid() ? Target.Position2D : (IsCharmed ? Manager.Instance.Player.Position2D : Position2D);

		if ( IsShooting )
		{
			Velocity *= (1f - dt * (IsAttacking ? DecelerationAttacking : Deceleration));
			if ( !_hasShot && _prepareStartTime > 1f )
			{
				CreateSpike();
				_hasShot = true;
			}

			if ( !_hasReversed && _prepareStartTime > 3f )
			{
				_hasReversed = true;
				Sprite.PlayAnimation( "shoot_reverse" );
			}

			Velocity *= (1f - dt * 4f);

			_shotTimer -= dt;
			if ( _shotTimer < 0f )
			{
				FinishShooting();
				return;
			}
		}
		else if ( IsDigging )
		{
			Velocity *= (1f - dt * 6f);

			if ( _timeSinceStartDigging > DIG_TIME )
			{
				Vector2 pos;
				if ( Target.IsValid() )
				{
					pos = targetPos + Target.Velocity * Game.Random.Float( 0f, 2f ) + Utils.GetRandomVector() * Game.Random.Float( 1.5f, 5.5f );
					if ( (Position2D - pos).LengthSquared < MathF.Pow( 0.5f, 2f ) )
						pos = Position2D + Utils.GetRandomVector() * Game.Random.Float( 3f, 5f );
				}
				else
				{
					pos = Position2D + Game.Random.Float( 4f, 5f );
				}

				FinishDigging( Manager.Instance.ClampToBounds( pos ) );
			}
			else
			{
				float progress = Utils.Map( _timeSinceStartDigging, 0f, DIG_TIME, 0f, 1f );
				//ZPos = Utils.Map( progress, 0f, 1f, 0f, DIG_DEPTH );
				//PlaybackRate = Utils.Map( progress, 0f, 1f, 0f, 1f ) * _personalSpeedScale;

				float shadowOpacity = Utils.Map( progress, 0f, 1f, ShadowOpacity, 0f, EasingType.QuadIn );
				ShadowSprite.Tint = Color.Black.WithAlpha( shadowOpacity );

				VfxOpacity = Utils.Map( progress, 0f, 1f, 1f, 0f, EasingType.QuadIn );

				//Gizmo.Draw.Color = Color.White;
				//Gizmo.Draw.Text( $"{progress}", new global::Transform( (Vector3)Position2D + new Vector3( 0f, -0.4f, 0f ) ) );

				IgnoreCollision = progress > 0.3f;

				if ( _spawnCloudTime > (0.3f / TimeScale) )
				{
					var cloud = Manager.Instance.SpawnCloud( Position2D + new Vector2( 0f, 0.05f ) );
					cloud.Velocity = new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ).Normal * Game.Random.Float( 0.2f, 0.6f );
					_spawnCloudTime = Game.Random.Float( 0f, 0.15f );
				}
			}

			return;
		}
		else
		{
			Vector2 toTarget = (targetPos - Position2D).Normal;

			if ( Manager.Instance.Difficulty >= 1 )
			{
				if ( (targetPos - Position2D).LengthSquared < MathF.Pow( _perpendicularMaxDist, 2f ) )
					toTarget = Vector2.Lerp( toTarget, new Vector2( toTarget.y, -toTarget.x ) * (_moveClockwise ? -1f : 1f), Utils.Map( (targetPos - Position2D).LengthSquared, MathF.Pow( _perpendicularMaxDist, 2f ), MathF.Pow( 1.5f, 2f ), 0f, 1f ) );
			}

			Velocity += toTarget * 1.0f * dt * (IsFeared ? -1f : 1f);
		}

		float speed = 0.5f * (IsAttacking ? 1.3f : 0.7f) + Utils.FastSin( MoveTimeOffset + Time.Now * (IsAttacking ? 12f : 4.5f) ) * (IsAttacking ? 0.66f : 0.35f);

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

		WorldPosition += (Vector3)Velocity * speed * dt;

		if ( !IsShooting && !IsDigging && (!IsAttacking || IsCharmed) && !Manager.Instance.IsGameOver )
		{
			var target_dist_sqr = (targetPos - Position2D).LengthSquared;

			var range = MathF.Pow( 7.5f, 2f );

			if ( Manager.Instance.Difficulty < 0 )
				range *= 0.7f;

			if ( target_dist_sqr < range )
			{
				_shootDelayTimer -= dt;
				if ( _shootDelayTimer < 0f )
					StartShooting();
			}

			if ( target_dist_sqr < MathF.Pow( 14f, 2f ) && Manager.Instance.Difficulty > 0 )
			{
				_digDelayTimer -= dt;
				if ( _digDelayTimer < 0f )
					StartDigging();
			}
		}
	}

	protected override void UpdateSprite( Thing target )
	{
		if ( Sprite.CurrentAnimation.Name.Contains( "shoot" ) || IsDigging ) return;

		base.UpdateSprite( target );
	}

	public void StartShooting()
	{
		_shotTimer = SHOOT_TIME;
		IsShooting = true;
		CanAttack = false;
		CanAttackAnim = false;
		CanTurn = false;
		_hasShot = false;
		_hasReversed = false;
		_prepareStartTime = 0f;
		Velocity *= 0.25f;
		DontChangeAnimSpeed = true;
		AnimSpeed = 1f;
		BroadcastShootAnim();

		ShouldUpdateAfterGameOver = true;
	}

	void BroadcastShootAnim()
	{
		Sprite.PlayAnimation( "shoot" );
	}

	public void CreateSpike()
	{
		var target_pos = Target.IsValid()
			? Target.Position2D + Target.Velocity * Game.Random.Float( 0.1f, 3f ) + new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * 1.2f
			: Position2D + Utils.GetRandomVector() * Game.Random.Float( 3.5f, 6f );

		var BUFFER = 0.125f;

		var spike = Manager.Instance.SpawnEnemySpikeElite(
			new Vector2( Math.Clamp( target_pos.x, Manager.Instance.BOUNDS_MIN.x + BUFFER, Manager.Instance.BOUNDS_MAX.x - BUFFER ), Math.Clamp( target_pos.y, Manager.Instance.BOUNDS_MIN.y + BUFFER, Manager.Instance.BOUNDS_MAX.y - BUFFER ) ),
			target: Target
		);

		if(IsCharmed)
		{
			spike.BecomeCharmed();
		}

		Manager.Instance.PlaySfxNearby( "spike.prepare", target_pos, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1.5f, maxDist: 5f );
	}

	public void FinishShooting()
	{
		_shootDelayTimer = Game.Random.Float( SHOOT_DELAY_MIN, SHOOT_DELAY_MAX ) * (Manager.Instance.Difficulty < 0 ? 2.5f : 1f);
		IsShooting = false;
		CanAttack = true;
		CanAttackAnim = true;
		CanTurn = true;
		DontChangeAnimSpeed = false;
		BroadcastIdleAnim();

		ShouldUpdateAfterGameOver = false;
		if ( Manager.Instance.IsGameOver && !Manager.Instance.ShouldUpdateThings )
			Celebrate();
	}

	void BroadcastIdleAnim()
	{
		Sprite.PlayAnimation( AnimIdlePath );
	}

	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 Damage( float damage, Player player, Vector2 addVel, float addTempWeight, bool isCrit = false, DamageType damageType = DamageType.PlayerBullet )
	{
		base.Damage( damage, player, addVel, addTempWeight, isCrit, damageType );

		if ( Game.Random.Float( 0f, 1f ) < Utils.Map( damage, 1f, 20f, 0.1f, 0.7f ) )
			_digDelayTimer *= Game.Random.Float( 0.6f, 0.95f );
	}

	public void StartDigging()
	{
		IsDigging = true;
		_timeSinceStartDigging = 0f;
		CanAttack = false;
		CanAttackAnim = false;
		CanTurn = false;
		Velocity *= 0.5f;
		Sprite.PlayAnimation( "dig" );
		Manager.Instance.PlaySfxNearby( "zombie.dirt", Position2D, pitch: Game.Random.Float( 0.5f, 0.55f ), volume: 0.6f, maxDist: 7.5f );
		//SS2Game.PlaySfx( "zombie.dirt", Position, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.7f );
		//SS2Game.Current.DustCloudClient( Position2D );

		ShouldUpdateAfterGameOver = true;
	}

	void FinishDigging( Vector2 pos )
	{
		Position2D = pos;
		//WorldPosition = ((Vector3)Position2D).WithZ( ZPos );

		Transform.ClearInterpolation();

		IsDigging = false;
		CanAttack = true;
		CanAttackAnim = true;
		CanTurn = true;
		IgnoreCollision = false;
		//SetAnim( "Attack1" );

		_moveClockwise = !_moveClockwise;

		_digDelayTimer = Game.Random.Float( DIG_DELAY_MIN, DIG_DELAY_MAX );

		//SS2Game.PlaySfx( "zombie.dirt", Position, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.7f );
		//SS2Game.Current.DustCloudClient( Position2D );

		IsSpawning = true;
		//_hasDug = true;
		TimeSinceSpawn = 0f;
		//SpawnProgress = 0f;

		//ShadowRadiusModifier = 1.5f;
		//ShadowOpacityModifier = 0f;

		Manager.Instance.PlaySfxNearby( "zombie.dirt", Position2D, pitch: Game.Random.Float( 0.85f, 0.9f ), volume: 0.6f, maxDist: 7.5f );

		ShadowSprite.Tint = Color.Black.WithAlpha( 0f );
		VfxOpacity = 0f;
	}

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

		if ( IsShooting || IsDigging )
			return;

		CelebrateAsync();
	}

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

		Sprite.PlaybackSpeed = Game.Random.Float( 3.5f, 4.5f );

		Sprite.PlayAnimation( "cheer_start" );

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

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

		Sprite.PlayAnimation( "cheer" );
	}

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

		ShouldUpdateAfterGameOver = false;
		if ( Manager.Instance.IsGameOver && !Manager.Instance.ShouldUpdateThings )
			Celebrate();
	}
}