things/enemies/Runner.cs
using Sandbox;
using System.Drawing;

public class Runner : Enemy
{
	private TimeSince _damageTime;
	private const float DAMAGE_TIME = 0.5f;

	public bool IsWandering { get; private set; }
	private Vector2 _wanderPos;

	public override float HeightVariance => 0.04f;
	public override float WidthVariance => 0.02f;

	private bool _isJumping;
	private Vector2 _jumpStartPos;
	private Vector2 _jumpTargetPos;
	private TimeSince _timeSinceBeginJump;
	private float _jumpTime;
	private float _jumpHeight;

	private bool _isPreparingJump;
	private float _prepareJumpTime;
	private TimeSince _timeSincePrepareJump;
	private TimeSince _timeSinceJumping;
	private float _delayUntilNextJump;

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

		Scale = 1.0f;

		base.OnAwake();

		//AnimSpeed = 2f;
		//Sprite.Texture = Texture.Load("textures/sprites/runner.vtex");

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

		PushStrength = 10f;
		Deceleration = 0.47f;
		DecelerationAttacking = 0.33f;
		AggroRange = 2.5f;

		Radius = 0.25f;

		Health = 70f;

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

		MaxHealth = Health;
		DamageToPlayer = 8f;

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

		Sprite.PlayAnimation( AnimSpawnPath );

		if ( IsProxy )
			return;
		
		CollideWith.Add( typeof( Enemy ) );
		CollideWith.Add( typeof( Player ) );

		//ShadowScale = 0.95f;
		_damageTime = DAMAGE_TIME;

		IsWandering = true;

		_wanderPos = new Vector2( Game.Random.Float( Manager.Instance.BOUNDS_MIN.x + 1f, Manager.Instance.BOUNDS_MAX.x - 1f ), Game.Random.Float( Manager.Instance.BOUNDS_MIN.y + 1f, Manager.Instance.BOUNDS_MAX.y - 2f ) );

		TintFullHp = new Color( 0.55f, 0.55f, 0.55f );
		TintZeroHp = new Color( 0.2f, 0.2f, 0.2f );
		TintDeath = new Color( 0.3f, 0.3f, 0.3f );
		Sprite.Tint = TintFullHp;

		_delayUntilNextJump = Game.Random.Float( 0.5f, 5f );
	}

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

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

		if ( Manager.Instance.Difficulty >= 1 && !IsWandering && !_isPreparingJump && !_isJumping && _timeSinceJumping > _delayUntilNextJump * ( IsAttacking ? 2f : 1f ) && !Manager.Instance.IsGameOver )
		{
			PrepareJump( targetPos + targetVel * Game.Random.Float( 0f, 4f ) + Utils.GetRandomVector() * Game.Random.Float(0f, 1f) );
		}

		if( _isPreparingJump )
		{
			if ( Manager.Instance.IsGameOver )
				_isPreparingJump = false;

			if(_timeSincePrepareJump > _prepareJumpTime )
			{
				Jump();
			}

			Velocity *= (1f - dt * 1.75f);
			WorldPosition += (Vector3)Velocity * dt;

			return;
		}

		if(_isJumping)
		{
			if(_timeSinceBeginJump > _jumpTime)
			{
				FinishJump();
			}
			else
			{
				float progress = Utils.Map( _timeSinceBeginJump, 0f, _jumpTime, 0f, 1f, EasingType.Linear );
				Position2D = Vector2.Lerp( _jumpStartPos, _jumpTargetPos, progress );
				Sprite.LocalPosition = new Vector3( 0f, Utils.MapReturn( progress, 0f, 1f, 0f, _jumpHeight, EasingType.SineOut ), 0f );

				float shadowScale = Utils.MapReturn( progress, 0f, 1f, ShadowScale, ShadowScale * 0.5f, EasingType.SineOut );
				ShadowSprite.LocalScale = new Vector3( shadowScale * Globals.SPRITE_SCALE, shadowScale * Globals.SPRITE_SCALE, 1f );

				float shadowOpacity = Utils.MapReturn( progress, 0f, 1f, ShadowOpacity, ShadowOpacity * 0.5f, EasingType.SineOut );
				ShadowSprite.Tint = Color.Black.WithAlpha( shadowOpacity );
			}

			return;
		}

		if ( !IsWandering  )
		{
			Velocity += (targetPos - Position2D).Normal * dt * (IsFeared ? -1f : 1f);
		}
		else
		{
			var wander_dist_sqr = (_wanderPos - Position2D).LengthSquared;
			if ( wander_dist_sqr < 0.25f )
			{
				_wanderPos = new Vector2( MathX.Clamp( targetPos.x + Game.Random.Float( -5f, 5f ), Manager.Instance.BOUNDS_MIN.x + 1f, Manager.Instance.BOUNDS_MAX.x - 1f ), MathX.Clamp( targetPos.y + Game.Random.Float( -5f, 5f ), Manager.Instance.BOUNDS_MIN.y + 1f, Manager.Instance.BOUNDS_MAX.y - 2f ) );
			}

			Velocity += (_wanderPos - Position2D).Normal * dt;

			var timeSinceInputMove = Target is Player player ? player.TimeSinceInputMove.Relative : 0f;
			float detect_dist = timeSinceInputMove < 20f ? 3.5f : Utils.Map( timeSinceInputMove, 20f, 400f, 8f, 30f, EasingType.Linear );

			var player_dist_sqr = (targetPos - Position2D).LengthSquared;
			if ( player_dist_sqr < detect_dist * detect_dist )
			{
				IsWandering = false;
				_delayUntilNextJump = Game.Random.Float( 0.5f, 7f );

				if ( detect_dist < 4f )
					Manager.Instance.PlaySfxNearby( "runner.howl", Position2D, pitch: Game.Random.Float( 0.9f, 1.1f ), volume: 1f, maxDist: 6f );
			}

			if ( TimeSinceSpawn > 60f )
			{
				IsWandering = false;
				_delayUntilNextJump = Game.Random.Float( 0.5f, 7f );
			}
		}

		float speed = (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.75f;
		//else if ( Manager.Instance.Difficulty >= 1 )
		//	speed *= 1.5f;

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

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

		Manager.Instance.PlaySfxNearby( "runner.bark", Position2D, pitch: Game.Random.Float( 0.9f, 1.1f ), volume: 1f, maxDist: 4f );
	}

	protected override void UpdateSprite( Thing target )
	{
		if ( _isPreparingJump || _isJumping )
			return;

		base.UpdateSprite( target );
	}

	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( "runner.bite", Position2D, pitch: Utils.Map( enemy.Health, enemy.MaxHealth, 0f, 0.9f, 0.95f, 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( "runner.bite", Position2D, pitch: Utils.Map( player.Health, player.Stats[PlayerStat.MaxHp], 0f, 0.9f, 0.95f, EasingType.QuadIn ), volume: 1f, maxDist: 5.5f );

						player.Damage( dmg );

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

					_damageTime = 0f;
				}
			}
		}
	}

	public void PrepareJump( Vector2 targetPos )
	{
		var BUFFER = 0.3f;
		targetPos = new Vector2( MathX.Clamp( targetPos.x, Manager.Instance.BOUNDS_MIN.x + BUFFER, Manager.Instance.BOUNDS_MAX.x - BUFFER ), MathX.Clamp( targetPos.y, Manager.Instance.BOUNDS_MIN.y + BUFFER, Manager.Instance.BOUNDS_MAX.y - BUFFER ) );

		float MAX_DIST = Game.Random.Float(3.7f, 4.1f);

		if ( (targetPos - Position2D).Length > MAX_DIST )
			targetPos = Position2D + (targetPos - Position2D).Normal * MAX_DIST;

		_jumpTargetPos = targetPos;
		_jumpTime = Utils.Map( (targetPos - Position2D).Length, 1.5f, MAX_DIST, 0.65f, 1.15f, EasingType.SineIn ) * Game.Random.Float( 0.85f, 1.1f );
		_jumpHeight = Utils.Map( _jumpTime, 0.7f, 3f, 1.8f, 3f ) * Game.Random.Float( 0.9f, 1.1f );

		_isPreparingJump = true;
		_timeSincePrepareJump = 0f;
		_prepareJumpTime = Game.Random.Float( 0.2f, 0.6f );
		IsAttacking = false;
		CanTurn = false;
		CanAttack = false;
		CanAttackAnim = false;

		FlipX = targetPos.x > Position2D.x;

		Sprite.PlayAnimation( "jump_prepare" );
	}

	public void Jump()
	{
		if ( Manager.Instance.IsGameOver )
			return;

		_isPreparingJump = false;
		_isJumping = true;
		_jumpStartPos = Position2D;
		_timeSinceBeginJump = 0f;
		IgnoreCollision = true;
		ShouldUpdateAfterGameOver = true;

		Sprite.PlayAnimation( "jump" );

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

	void FinishJump()
	{
		_isJumping = false;
		Position2D = _jumpTargetPos;
		Sprite.LocalPosition = Vector3.Zero;
		IgnoreCollision = false;
		CanTurn = true;
		CanAttack = true;
		CanAttackAnim = true;

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

		_timeSinceJumping = 0f;
		_delayUntilNextJump = Game.Random.Float( 0.5f, 8f );

		ShadowSprite.LocalScale = new Vector3( ShadowScale * Globals.SPRITE_SCALE, ShadowScale * Globals.SPRITE_SCALE, 1f );
		ShadowSprite.Tint = Color.Black.WithAlpha( ShadowOpacity );

		Manager.Instance.PlaySfxNearby( "jump_thud", Position2D, pitch: Game.Random.Float( 0.95f, 1f ), volume: 0.55f, maxDist: 6f );

		for (int i = 0; i < Game.Random.Int(2, 4); i++ )
		{
			SpawnCloudClient( Position2D, Utils.GetRandomVector() * Game.Random.Float(0.3f, 1.5f) );
		}

		if ( Target.IsValid() )
			Velocity += (Target.Position2D - Position2D).Normal * Game.Random.Float( -0.2f, 2f );
	}

	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 );

		IsWandering = false;
	}
	public override void Celebrate()
	{
		base.Celebrate();

		if ( _isJumping )
			return;

		CelebrateAsync();
	}

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

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

		Sprite.PlayAnimation( "cheer_start" );

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

		Sprite.PlayAnimation( "cheer" );
	}
}