things/enemies/Zombie.cs

Enemy subclass for a zombie NPC. Controls stats, movement behaviors (wandering, targeting, several AI variants for higher difficulty), collisions with players and other enemies, damage application, and simple celebrate animation logic.

File Access
using Sandbox;
using Sandbox.Diagnostics;
using System.Reflection;

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

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

	public override float HeightVariance => 0.065f;
	public override float WidthVariance => 0.035f;

	[Property] public Sprite EvolvedSprite { get; set; }
	[Property] public Sprite EvolvedBloodySprite { get; set; }

	private bool _hasHitPlayer;

	private float _movespeed;
	private float _movespeedAttacking;
	private int _behaviourNum;
	private TimeSince _timeSinceHurt;
	private float _behaviourRand0;
	private float _behaviourRand1;
	private float _behaviourRandSpeed0;
	private float _behaviourRandSpeed1;

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

		Scale = 0.85f;

		base.OnAwake();

		Sprite.LocalScale = Sprite.LocalScale.WithX( Sprite.LocalScale.x * Game.Random.Float( 0.9f, 1.1f ) );

		PushStrength = 10f;

		Radius = 0.25f;

		Health = 30f;

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

		MaxHealth = Health;
		DamageToPlayer = 7f;

		Sprite.PlayAnimation( AnimSpawnPath );

		TintDeath = new Color( 0.7f, 0.7f, 0.7f );

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

		_damageTime = DAMAGE_TIME;

		IsWandering = Game.Random.Float(0f, 1f) < Utils.Map( Manager.Instance.ElapsedTime, 0f, 5f * 60f, 1f, 0f, EasingType.SineIn);

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

		_movespeed = 0.7f;
		_movespeedAttacking = 1.3f;

		if ( Manager.Instance.Difficulty >= 4 )
		{
			DamageToPlayer = 18f;

			//Health = 32f;
			//MaxHealth = Health;

			Sprite.Sprite = EvolvedSprite;

			//TintFullHp = new Color( 0f, 0.3f, 0f );
			//TintZeroHp = new Color( 0.1f, 0.3f, 0.3f );
			//TintDeath = new Color( 0.2f, 0.5f, 0.5f );

			_movespeed = 1f;
			_movespeedAttacking = 1.32f;

			_behaviourRand0 = Game.Random.Float( 0f, 99f );
			_behaviourRand1 = Game.Random.Float( 0f, 99f );
			_behaviourRandSpeed0 = Game.Random.Float( 0.7f, 1.3f );
			_behaviourRandSpeed1 = Game.Random.Float( 0.7f, 1.3f );

			float behaviourRand = Game.Random.Float( 0f, 1f );
			if ( behaviourRand < 0.65f )
				_behaviourNum = 0;
			else if (behaviourRand < 0.7f)
				_behaviourNum = 1;
			else if ( behaviourRand < 0.85f )
				_behaviourNum = 2;
			else if ( behaviourRand < 0.9f )
				_behaviourNum = 3;
			else if ( behaviourRand < 0.95f )
				_behaviourNum = 4;
			else
				_behaviourNum = 5;
		}
	}

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

		//Gizmo.Draw.Text( $"{_behaviourNum}", new global::Transform( (Vector3)Position2D + new Vector3( 0f, -0.2f, 0f ) ) );

		var manager = Manager.Instance;

		var targetPos = Position2D;

		if( Target.IsValid() )
		{
			targetPos = Target.Position2D;

			//if( Target is Player player && EnemyIdNum % 2 == 0 )
			//{
			//	var distSqr = (Target.Position2D - Position2D).LengthSquared;

			//	Vector2 anchor = player.Position2D + player.AverageVelocity * Utils.Map( distSqr, 0f, 10f * 10f, 0f, 1f);
			//	Vector2 perp = Utils.GetPerpendicularVector( player.AverageVelocity ).Normal;
			//	var perpA = anchor - perp * 10f;
			//	var perpB = anchor + perp * 10f;
			//	var isBehindPlayer = Utils.CheckPointSideOfLine( perpA, perpB, Position2D );

			//	if ( distSqr < 7f )
			//	{
			//		targetPos = Target.Position2D;

			//		//Gizmo.Draw.Color = Color.Yellow.WithAlpha( 0.4f );
			//		//Gizmo.Draw.Line( Position2D, targetPos );
			//	}
			//	else if( !isBehindPlayer || player.AverageVelocity.LengthSquared < 1f )
			//	{
			//		targetPos = player.Position2D + player.AverageVelocity * Utils.Map( distSqr, 7f, 12f, 0f, 1.2f );

			//		//Gizmo.Draw.Color = Color.Yellow.WithAlpha( 0.4f );
			//		//Gizmo.Draw.Line( Position2D, targetPos );
			//	}
			//	else
			//	{
			//		//targetPos = player.Position2D + player.AverageVelocity * Utils.Map( distSqr, 7f, 12f, 0f, 1.2f );

			//		targetPos = Position2D + player.AverageVelocity;

			//		//Gizmo.Draw.Color = Color.Blue.WithAlpha( 0.4f );
			//		//Gizmo.Draw.Line( Position2D, targetPos );
			//	}
			//}
			//else
			//{
			//	targetPos = Target.Position2D;
			//}
		}
		else if(IsCharmed)
		{
			targetPos = manager.Player.Position2D;
		}

		if ( !IsWandering )
		{
			if( Manager.Instance.Difficulty >= 4 )
			{
				switch(_behaviourNum)
				{
					case 0: default:
						Velocity += (targetPos - Position2D).Normal * dt * (IsFeared ? -1f : 1f);
						break;
					case 1:
						if(_timeSinceHurt > 0.5f && Utils.FastSin( (TimeSinceSpawn + _timeSinceHurt) * 0.5f ) > 0.85f )
							Velocity += Utils.GetPerpendicularVector((targetPos - Position2D).Normal) * Utils.FastSin(TimeSinceSpawn * 0.5f) * 2f * dt * (IsFeared ? -1f : 1f);
						else
							Velocity += (targetPos - Position2D).Normal * dt * (IsFeared ? -1f : 1f);

						break;
					case 2:
						if ( Target.IsValid() )
							targetPos = Target.Position2D + Target.Velocity * 3f * (0.8f + Utils.FastSin( _behaviourRand0 + TimeSinceSpawn * _behaviourRandSpeed0 ) * 0.5f);

						Velocity += (targetPos - Position2D).Normal * dt * (IsFeared ? -1f : 1f);

						break;
					case 3:
						if(!IsAttacking)
							targetPos = Position2D + new Vector2( Utils.FastSin( _behaviourRand0 + TimeSinceSpawn * _behaviourRandSpeed0 ), Utils.FastSin( _behaviourRand1 + TimeSinceSpawn * _behaviourRandSpeed1 ) );

						Velocity += (targetPos - Position2D).Normal * dt * (IsFeared ? -1f : 1f);

						break;
					case 4:
						if ( Target.IsValid() && Target.Velocity.LengthSquared > 0f )
							targetPos = Position2D + Utils.GetPerpendicularVector( Target.Velocity ) * (Target.Position2D.x < Position2D.x ? -1f : 1f);

						Velocity += (targetPos - Position2D).Normal * dt * (IsFeared ? -1f : 1f);

						break;
					case 5:
						if ( Target.IsValid() && Target is Player player )
							targetPos = player.Position2D - player.AimDir * 1f + Utils.FastSin( _behaviourRand0 + TimeSinceSpawn * _behaviourRandSpeed0 ) * 1f;

						Velocity += (targetPos - Position2D).Normal * dt * (IsFeared ? -1f : 1f);

						break;
				}

				//return Position2D + Utils.GetPerpendicularVector( Target.Velocity ) * (Target.Position2D.x < Position2D.x ? -1f : 1f);
			}
			else
			{
				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.BOUNDS_MIN.x + 1f, manager.BOUNDS_MAX.x - 1f ), MathX.Clamp( targetPos.y + Game.Random.Float( -5f, 5f ), manager.BOUNDS_MIN.y + 1f, manager.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, 9f, 30f, EasingType.Linear );

			var player_dist_sqr = (targetPos - Position2D).LengthSquared;
			if ( player_dist_sqr < detect_dist * detect_dist )
			{
				IsWandering = false;

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

			if ( TimeSinceSpawn > 60f )
				IsWandering = false;
		}

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

		if ( manager.Difficulty < 0 )
			speed *= 0.85f;
		//else if ( manager.Difficulty >= 4 )
		//	speed *= 1.5f;

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

	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;

				if(enemy.TimeSinceTakeInfightingDamage > 0.25f)
					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 );

				enemy.TimeSinceTakeInfightingDamage = 0f;
			}
		}
		// todo: move collision check to player instead to prevent laggy hits?
		else if ( other is Player player )
		{
			if ( !player.IsDead )
			{
				//var oldVel = Velocity;
				Velocity += (Position2D - player.Position2D).Normal * Utils.Map( percent, 0f, 1f, 0f, 1f ) * player.Stats[PlayerStat.PushStrength] * (1f + player.TempWeight) * dt;

				//Log.Info( $"Colliding - dt: {dt} oldVel: {oldVel.Length} newVel: {Velocity.Length} now: {Time.Now} {(dt > 0.1f ? "----------------------" : "")}" );

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

							if(!_hasHitPlayer && Manager.Instance.Difficulty >= 4 && !IsDying)
							{
								Sprite.Sprite = EvolvedBloodySprite;
								_hasHitPlayer = true;
							}
						}
					}

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

		IsWandering = false;
		_timeSinceHurt = 0f;
	}

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