things/enemies/Charger.cs
using Sandbox;
using Sandbox.ModelEditor.Nodes;
using System.Net.NetworkInformation;

public class Charger : Enemy
{
	private TimeSince _damageTime;
	private const float DAMAGE_TIME = 1f;

	protected float _chargeDelayTimer;
	private const float CHARGE_DELAY_MIN = 2f;
	private const float CHARGE_DELAY_MAX = 6f;

	public bool IsPreparingToCharge { get; private set; }
	public bool IsCharging { get; private set; }
	private float _prepareTimer;
	private const float PREPARE_TIME = 1f;
	protected float _chargeTimer;
	protected float CHARGE_TIME_MIN = 1.8f;
	protected float CHARGE_TIME_MAX = 2.5f;
	private float _chargeTime;
	private float _nextRedirectTime;
	private TimeSince _timeSinceRedirect;

	protected Vector2 _chargeDir;
	protected Vector2 _chargeVel;
	private TimeSince _chargeCloudTimer;

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

	public float ChargeRange { get; set; }

	protected float REDIRECT_DELAY_MIN = 0.3f;
	protected float REDIRECT_DELAY_MAX = 2.7f;

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

		Scale = 1.25f;

		base.OnAwake();

		//Sprite.Texture = Texture.Load("textures/sprites/charger.vtex");
		//Sprite.Size = new Vector2( 1f, 1f ) * Scale;

		PushStrength = 25f;

		Radius = 0.275f;

		Health = 75f;

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

		MaxHealth = Health;
		DamageToPlayer = 10f;

		CoinValueMin = 2;
		CoinValueMax = 5;
		CoinChance = 0.7f;

		Sprite.PlayAnimation( AnimSpawnPath );

		if ( IsProxy )
			return;

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

		_damageTime = DAMAGE_TIME;
		_chargeDelayTimer = Game.Random.Float( CHARGE_DELAY_MIN, CHARGE_DELAY_MAX );

		ChargeRange = 4.2f;
	}

	protected override void UpdatePosition( float dt )
	{
		//Gizmo.Draw.Color = Color.White.WithAlpha(0.5f);
		//Gizmo.Draw.Text( $"IsCharging: {IsCharging}", new global::Transform( (Vector3)Position2D + new Vector3( 0f, -0.7f, 0f ) ) );

		base.UpdatePosition( dt );

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

		if ( IsPreparingToCharge )
		{
			_prepareTimer -= dt;
			if ( _prepareTimer < 0f )
			{
				Charge();
				return;
			}
		}
		else if ( IsCharging )
		{
			_chargeTimer -= dt;
			if ( _chargeTimer < 0f )
			{
				IsCharging = false;
				Sprite.PlayAnimation( AnimIdlePath );
				CanTurn = true;
				DontChangeAnimSpeed = false;
			}
			else
			{
				HandleCharging( dt );
			}

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

			if ( Manager.Instance.Difficulty >= 1 && _timeSinceRedirect > _nextRedirectTime )
			{
				_chargeDir = (targetPos - Position2D).Normal;

				if(Math.Abs( targetPos.x - Position2D.x ) > 0.15f )
					FlipX = targetPos.x > Position2D.x;

				_nextRedirectTime = Game.Random.Float( REDIRECT_DELAY_MIN, REDIRECT_DELAY_MAX );
				_timeSinceRedirect = 0f;
			}

			if ( _chargeCloudTimer > 0.25f )
			{
				SpawnCloudClient( Position2D + new Vector2( 0f, 0.25f ), -_chargeDir * Game.Random.Float( 0.2f, 0.8f ) );
				_chargeCloudTimer = Game.Random.Float( 0f, 0.075f );
			}
		}
		else
		{
			Velocity += (targetPos - Position2D).Normal * dt * (IsFeared ? -1f : 1f);

			float speed = 0.75f * (IsAttacking ? 1.3f : 0.7f) + Utils.FastSin( MoveTimeOffset + Time.Now * (IsAttacking ? 15f : 7.5f) ) * (IsAttacking ? 0.66f : 0.35f);

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

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

		var player_dist_sqr = (targetPos - Position2D).LengthSquared;
		if ( !IsPreparingToCharge && !IsCharging && !IsAttacking && player_dist_sqr < ChargeRange * ChargeRange && Target.IsValid() )
		{
			_chargeDelayTimer -= dt;
			if ( _chargeDelayTimer < 0f )
			{
				PrepareToCharge();
			}
		}
	}

	protected virtual void HandleCharging(float dt)
	{
		float chargeSpeed = Manager.Instance.Difficulty >= 1 ? 12f : 4f; ;
		_chargeVel += _chargeDir * chargeSpeed * Utils.MapReturn( _chargeTimer, _chargeTime, 0f, 0f, 1f, EasingType.Linear ) * dt;
		TempWeight += Utils.MapReturn( _chargeTimer, _chargeTime, 0f, 1f, 6f, EasingType.Linear ) * dt;

		float BUFFER = 0.01f;
		var x_min = Manager.Instance.BOUNDS_MIN.x + Radius + BUFFER;
		var x_max = Manager.Instance.BOUNDS_MAX.x - Radius - BUFFER;
		var y_min = Manager.Instance.BOUNDS_MIN.y + BUFFER;
		var y_max = Manager.Instance.BOUNDS_MAX.y - Radius - BUFFER;

		if ( Position2D.x < x_min && _chargeDir.x < 0f )
		{
			_chargeDir = _chargeDir.WithX( Math.Abs( _chargeDir.x ) );
			_chargeVel = _chargeVel.WithX( Math.Abs( _chargeVel.x ) * 0.1f );
			FlipX = true;
			Sprite.SpriteFlags = SpriteFlags.HorizontalFlip;
		}
		else if ( Position2D.x > x_max && _chargeDir.x > 0f )
		{
			_chargeDir = _chargeDir.WithX( -Math.Abs( _chargeDir.x ) );
			_chargeVel = _chargeVel.WithX( -Math.Abs( _chargeVel.x ) * 0.1f );
			FlipX = false;
			Sprite.SpriteFlags = SpriteFlags.None;
		}

		if ( Position2D.y < y_min && _chargeDir.y < 0f )
		{
			_chargeDir = _chargeDir.WithY( Math.Abs( _chargeDir.y ) );
			_chargeVel = _chargeDir.WithY( Math.Abs( _chargeVel.y ) * 0.1f );
		}
		else if ( Position2D.y > y_max && _chargeDir.y > 0f )
		{
			_chargeDir = _chargeDir.WithY( -Math.Abs( _chargeDir.y ) );
			_chargeVel = _chargeDir.WithY( -Math.Abs( _chargeVel.y ) * 0.1f );
		}
	}

	protected override void HandleDeceleration( float dt )
	{
		if ( IsCharging )
		{
			Velocity *= (1f - dt * 1.75f);

			float decel = Manager.Instance.Difficulty >= 1 ? 3f : 0.5f;
			_chargeVel *= (1f - dt * decel);
		}
		else
		{
			base.HandleDeceleration( dt );
		}
	}

	protected override void UpdateSprite( Thing target )
	{
		if ( !IsCharging )
			base.UpdateSprite( target );
	}

	protected override void HandleAttacking( Thing target, float dt )
	{
		if ( !IsPreparingToCharge && !IsCharging )
			base.HandleAttacking( target, dt );
	}

	public void PrepareToCharge()
	{
		_prepareTimer = PREPARE_TIME;
		IsPreparingToCharge = true;
		Manager.Instance.PlaySfxNearby( "enemy.roar.prepare", Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 5f );
		Sprite.PlayAnimation( "charge_start" );
		CanTurn = false;
		CanAttack = false;
		CanAttackAnim = false;
		DontChangeAnimSpeed = true;

		if ( Manager.Instance.Difficulty >= 1 )
		{
			_nextRedirectTime = Game.Random.Float( REDIRECT_DELAY_MIN, REDIRECT_DELAY_MAX );
			_timeSinceRedirect = 0f;
		}
	}

	public void Charge()
	{
		var target_pos = Target.IsValid() && !IsFeared
			? Target.Position2D + Target.Velocity * Game.Random.Float( 0.5f, 1.75f )
			: Position2D + Utils.GetRandomVector() * 0.5f;

		_chargeDir = Utils.RotateVector( (target_pos - Position2D).Normal, Game.Random.Float( -10f, 10f ) );

		IsPreparingToCharge = false;
		IsCharging = true;
		_chargeTime = Game.Random.Float( CHARGE_TIME_MIN, CHARGE_TIME_MAX ) * (Manager.Instance.Difficulty >= 1 ? Game.Random.Float(1.25f, Utils.Map(Manager.Instance.Difficulty, 1, 10, 1.75f, 3f, EasingType.SineIn)) : 1f);

		_chargeTimer = _chargeTime;
		CanAttack = true;
		CanAttackAnim = true;

		_chargeDelayTimer = Game.Random.Float( CHARGE_DELAY_MIN, CHARGE_DELAY_MAX ) * (Manager.Instance.Difficulty < 0 ? 1.4f : 1f);
		_chargeVel = Vector2.Zero;
		Sprite.PlayAnimation( "charge_loop" );
		AnimSpeed = 3f;
		FlipX = _chargeDir.x > 0f;
		Sprite.SpriteFlags = FlipX ? SpriteFlags.HorizontalFlip : SpriteFlags.None;
		//Sprite.SpriteFlags = target_pos.x > Position2D.x ? SpriteFlags.HorizontalFlip : SpriteFlags.None;

		Manager.Instance.PlaySfxNearby( "enemy.roar", Position2D, pitch: Game.Random.Float( 0.925f, 1.075f ), volume: 1f, maxDist: 8f );
	}

	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 || IsCharging) && 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 || IsCharging) && _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 Celebrate()
	{
		base.Celebrate();

		CelebrateAsync();
	}

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

		Sprite.PlaybackSpeed = Game.Random.Float( 1f, 2.5f );

		Sprite.PlayAnimation( "cheer_start" );

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

		Sprite.PlayAnimation( "cheer" );
	}
}