things/enemies/ZombieElite.cs

Enemy NPC class ZombieElite, subclass of Zombie. Implements dash attack behavior, random walking, movement, rotation, animations, syncing state with RPCs, and difficulty-scaled stats.

Networking
using Sandbox;
using System;
using System.Runtime.Intrinsics.X86;

public class ZombieElite : Zombie
{
	public override EnemyType EnemyType => EnemyType.ZombieElite;

	public override float MeleeForce => 30f;
	public override float MeleeRagdollForce => State == ZombieEliteState.Dash ? Game.Random.Float( 2f, 3f ) : Game.Random.Float( 0.6f, 1.3f );
	public override float MeleeUpwardForceAmount => State == ZombieEliteState.Dash ? Game.Random.Float( 0f, 0.5f ) : Game.Random.Float( 0f, 0.3f );

	public override float GetMaxHealth()
	{
		switch ( Manager.Instance.Difficulty )
		{
			case 0: default: return 50f;
			case 1: return 55f;
			case 2: return 58f;
		}
	}

	[Property] public Material DashFlashMaterial { get; set; }

	public override Vector3 SpawnScale => new Vector3( 1.15f );

	private float _dashTimer;
	private float _dashDelay;
	private TimeSince _timeSincePrepareDash;
	private float _dashDelayMin;
	private float _dashDelayMax;
	private const float DASH_PREPARE_TIME = 2f;
	private float _dashTime;
	private float _dashTimeMin;
	private float _dashTimeMax;
	private const float DASH_STRENGTH = 2500f;
	private bool _isFlashActive;
	private TimeSince _timeSinceFlashColor;
	private const float FLASH_DELAY_START = 0.075f;
	private const float FLASH_DELAY_END = 0.03f;
	private float _dashRangeOuter;
	private float _dashRangeInner;
	private float _dashPrepareAnimSpeed;
	//private Vector2 _dashTargetDir;
	private float _prepareMinTurnSpeed;

	// Random walk configuration
	private float _randomWalkIntervalMin = 3f;
	private float _randomWalkIntervalMax = 12f;
	private float _randomWalkDurationMin = 1f;
	private float _randomWalkDurationMax = 10f;

	// Random walk state
	private float _randomWalkTimer;
	private float _randomWalkDuration;
	private bool _isRandomWalking;
	private Vector2 _randomWalkDirection;

	//public override bool CanTurn => State == ZombieEliteState.Default;
	public override bool CanAttack => base.CanAttack && (State == ZombieEliteState.Default || State == ZombieEliteState.Dash);
	public override bool CanDamageByTouch => !IsDying && !IsStunned && !IsInTheAir && (IsAttacking || State == ZombieEliteState.Dash) && State != ZombieEliteState.DashPrepare;

	protected enum ZombieEliteState
	{
		Default,
		DashPrepare,
		Dash,
	}

	[Sync] protected ZombieEliteState State { get; private set; } = ZombieEliteState.Default;

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

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

		Weight = 1.2f;
		PushStrength = 6500f;

		_personalSpeedScale = Game.Random.Float( 0.9f, 1.2f );
		_personalSpeedFreq = Game.Random.Float( 7f, 12f );

		ModelRenderer.SetBodyGroup( 0, 5 );

		RefreshMinPrepareTurnSpeed();

		if ( IsProxy )
			return;

		AggroRange = 90f;
		DetectTargetRange = 500f;
		LoseTargetRange = 900f;
		LoseTargetTime = 6f;
		MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 6f, 8f, 9f );
		DamageTargetDelay = 0.5f;
		_personalTurnSpeed = 6f;
		//Acceleration = 110f;
		//AccelerationAttacking = 140f;
		//Deceleration = 2f;
		//DecelerationAttacking = 1.65f;
		Acceleration = 450f * Utils.Select( Manager.Instance.Difficulty, 0.9f, 1f, 1.05f );
		AccelerationAttacking = 800f * Utils.Select( Manager.Instance.Difficulty, 0.9f, 1f, 1.05f );
		Deceleration = 1.95f;
		DecelerationAttacking = 1.75f;

		_dashRangeOuter = Utils.Select( Manager.Instance.Difficulty, 230f, 240f, 260f );
		_dashRangeInner = Utils.Select( Manager.Instance.Difficulty, 155f, 160f, 170f );
		_dashDelayMin = Utils.Select( Manager.Instance.Difficulty, 4f, 3.5f, 2.5f );
		_dashDelayMax = Utils.Select( Manager.Instance.Difficulty, 8f, 7f, 5.5f );
		_dashTimeMin = Utils.Select( Manager.Instance.Difficulty, 0.275f, 0.275f, 0.3f );
		_dashTimeMax = Utils.Select( Manager.Instance.Difficulty, 0.55f, 0.7f, 0.8f );

		_dashTimer = _dashDelay = Game.Random.Float( 0.5f, _dashDelayMax );

		_randomWalkTimer = Game.Random.Float( _randomWalkIntervalMin, _randomWalkIntervalMax );
	}

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

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"{_prepareMinTurnSpeed}", new global::Transform( WorldPosition ) );

		if ( IsInTheAir || Manager.Instance.IsGameOver )
			return;

		if ( IsDying )
			return;

		if ( State == ZombieEliteState.DashPrepare )
		{
			SetPlaybackRate( Utils.Map( _timeSinceChangeState, 0f, _dashTime, _dashPrepareAnimSpeed, 0f ) );

			HandleDashFlashing();
		}

		if ( IsProxy )
			return;

		HandleState();
	}

	void HandleDashFlashing()
	{
		if ( _timeSinceFlashColor > Utils.Map( _timeSincePrepareDash, DASH_PREPARE_TIME, 0f, FLASH_DELAY_START, FLASH_DELAY_END, EasingType.QuadOut ) )
		{
			_isFlashActive = !_isFlashActive;

			if ( _isFlashActive )
				ModelRenderer.SetMaterial( DashFlashMaterial );
			else
				ResetMaterial();

			_timeSinceFlashColor = 0f;
		}
	}

	protected void HandleState()
	{
		switch ( State )
		{
			case ZombieEliteState.Default:
				if ( HasTarget && TargetUnit.IsValid() && !IsStunned && !IsInTheAir )
				{
					// todo: increase dash range on higher difficulties
					var targetDistSqr = (TargetUnit.Position2D - Position2D).LengthSquared;
					if ( targetDistSqr < MathF.Pow( _dashRangeOuter, 2f ) )
					{
						_dashTimer -= Time.Delta;
						if ( _dashTimer < 0f && targetDistSqr < MathF.Pow( _dashRangeInner, 2f ) )
							SetState( ZombieEliteState.DashPrepare );
					}
				}

				// Handle random walk behavior
				if ( _isRandomWalking )
				{
					_randomWalkDuration -= Time.Delta;
					if ( _randomWalkDuration <= 0f )
					{
						_isRandomWalking = false;
						_randomWalkTimer = Game.Random.Float( _randomWalkIntervalMin, _randomWalkIntervalMax );
					}
				}
				else
				{
					_randomWalkTimer -= Time.Delta;
					if ( _randomWalkTimer <= 0f )
						StartRandomWalk();
				}

				break;
			case ZombieEliteState.DashPrepare:
				if ( _timeSinceChangeState > _dashTime )
					SetState( ZombieEliteState.Dash );

				break;
			case ZombieEliteState.Dash:
				// todo: move less fast when frozen
				Velocity += (Vector2)WorldRotation.Forward * Utils.Map( _timeSinceChangeState, 0f, _dashTime, DASH_STRENGTH, 0f, EasingType.QuadIn ) * Time.Delta;

				if ( _timeSinceChangeState > _dashTime )
				{
					Velocity *= 0.2f;

					SetState( ZombieEliteState.Default );
				}

				break;

		}
	}

	private void StartRandomWalk()
	{
		_isRandomWalking = true;
		_randomWalkDuration = Game.Random.Float( _randomWalkDurationMin, _randomWalkDurationMax );
		_randomWalkDirection = Utils.GetRandomVector();
	}

	protected void SetState( ZombieEliteState state )
	{
		State = state;
		_timeSinceChangeState = 0f;

		switch ( state )
		{
			case ZombieEliteState.Default:
				EnterDefaultStateRpc();

				break;
			case ZombieEliteState.DashPrepare:
				_isRandomWalking = false; // Cancel random walk when preparing to dash

				PrepareDashRpc();

				_dashTime = Game.Random.Float( _dashTimeMin, _dashTimeMax ) * Utils.Select( Manager.Instance.Difficulty, 0.7f, 1f, 1.2f );

				_timeSincePrepareDash = 0f;

				_dashDelay = Game.Random.Float( _dashDelayMin, _dashDelayMax );
				_dashTimer = _dashDelay;

				Velocity *= 0.2f;

				break;
			case ZombieEliteState.Dash:
				DashRpc();

				//_dashTargetDir = HasTarget ? (TargetPos - Position2D).Normal : (Vector2)WorldRotation.Forward;
				//_dashTargetDir = (Vector2)WorldRotation.Forward;

				break;
		}
	}

	protected override void HandleRotation()
	{
		// random walking
		if( _isRandomWalking && State == ZombieEliteState.Default && !IsAttacking && !ShouldRetreatFromTarget )
		{
			WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( ((Vector3)_randomWalkDirection).WithZ( 0f ) ), _personalTurnSpeed * Time.Delta * TimeScale );
			return;
		}

		if( State == ZombieEliteState.DashPrepare )
		{
			var prepareFacingDir = HasTarget ? (TargetPos - Position2D).Normal : (Vector2)WorldRotation.Forward;
			var turnEasingType = Manager.Instance.Difficulty switch
			{
				0 => EasingType.SineOut,
				1 => EasingType.QuadIn,
				2 => EasingType.ExpoIn,
				_ => EasingType.SineOut
			};
			var prepareTurnSpeedFactor = Utils.Map( _timeSinceChangeState, 0f, _dashTime, 1f, _prepareMinTurnSpeed, turnEasingType );
			WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( (Vector3)prepareFacingDir ), _personalTurnSpeed * 1.5f * prepareTurnSpeedFactor * Time.Delta * TimeScale );
			return;
		}

		if( State == ZombieEliteState.Dash )
		{
			return;
		}

		base.HandleRotation();
	}

	protected override void HandleMovement()
	{
		var moveDir = (Vector2)WorldRotation.Forward;

		if ( State == ZombieEliteState.Default )
		{
			float acceleration = (IsAttacking ? AccelerationAttacking : Acceleration);
			Velocity += moveDir * acceleration * Time.Delta * TimeScale * Manager.Instance.GlobalMovespeedModifier;
		}

		if ( Manager.Instance.IsWindActive )
			Velocity += (Manager.Instance.GlobalWindForce / (Weight * 3f)) * Time.Delta;

		Velocity *= Math.Max( 1f - Time.Delta * (IsAttacking ? DecelerationAttacking : Deceleration) * Manager.Instance.GlobalFrictionModifier, 0f );

		WorldPosition += (Vector3)Velocity * GetMoveSpeedFactor() * Time.Delta;
	}

	protected override float GetMoveSpeedFactor()
	{
		if ( State == ZombieEliteState.Dash )
			return 1f;

		return base.GetMoveSpeedFactor();
	}

	protected override Vector2 GetTargetOffset()
	{
		if ( State == ZombieEliteState.Default )
			return TargetUnit.Velocity * 2f * Utils.Map( _dashTimer, _dashDelay, 0f, 0f, 1f, EasingType.QuadIn ) * (0.5f + Utils.FastSin( TimeSinceSpawn * 2.1f ) * 0.5f);

		return Vector2.Zero;
	}

	[Rpc.Broadcast]
	public void PrepareDashRpc()
	{
		SetAnim( "Attack" );

		CanAnimate = false;

		_dashPrepareAnimSpeed = Game.Random.Float( 1f, 2f );
		SetPlaybackRate( _dashPrepareAnimSpeed );

		Manager.Instance.PlaySfxNearby( "pounce.prepare", Position2D, pitch: Game.Random.Float( 1.2f, 1.3f ), volume: 1.5f, maxDist: 450f );

		_timeSincePrepareDash = 0f;
	}

	[Rpc.Broadcast]
	public void DashRpc()
	{
		SetPlaybackRate( 0f );
		
		ResetMaterial();

		Manager.Instance.PlaySfxNearby( "pounce", Position2D, pitch: Game.Random.Float( 0.8f, 0.85f ), volume: 0.4f, maxDist: 450f );

		RefreshMinPrepareTurnSpeed();
	}

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

		PlayFlinchAnim();

		SetState( ZombieEliteState.Default );
	}

	[Rpc.Broadcast]
	public void EnterDefaultStateRpc()
	{
		CanAnimate = true;

		if ( IsAttacking )
			PlayAttackAnim();
		else
			PlayWalkAnim();

		ResetMaterial();
	}

	protected override void OnOutOfBounds( Direction direction )
	{
		base.OnOutOfBounds( direction );

		if ( State == ZombieEliteState.Dash )
		{
			var currentFacing = (Vector2)WorldRotation.Forward;
			Vector2 reflectedFacing = currentFacing;
			var normal = Vector2.Zero;

			if ( direction == Direction.Left )
				normal = new Vector2( 1f, 0f );
			else if ( direction == Direction.Right )
				normal = new Vector2( -1f, 0f );
			else if ( direction == Direction.Down )
				normal = new Vector2( 0f, 1f );
			else if ( direction == Direction.Up )
				normal = new Vector2( 0f, -1f );

			var dot = Vector2.Dot( currentFacing, normal );
			if ( dot > 0.2f )
				return;

			reflectedFacing = Utils.GetReflectedVector( currentFacing, normal );

			WorldRotation = Rotation.LookAt( reflectedFacing );

			Velocity *= 0.05f;

			// todo: sfx
		}
		else if ( State == ZombieEliteState.Default && _isRandomWalking ) // Reflect random walk direction off bounds
		{
			if ( direction == Direction.Left )
				_randomWalkDirection = new Vector2( -Math.Abs( _randomWalkDirection.x ), _randomWalkDirection.y );
			else if ( direction == Direction.Right )
				_randomWalkDirection = new Vector2( Math.Abs( _randomWalkDirection.x ), _randomWalkDirection.y );
			else if ( direction == Direction.Down )
				_randomWalkDirection = new Vector2( _randomWalkDirection.x, Math.Abs( _randomWalkDirection.y ) );
			else if ( direction == Direction.Up )
				_randomWalkDirection = new Vector2( _randomWalkDirection.x, -Math.Abs( _randomWalkDirection.y ) );
		}
	}

	void RefreshMinPrepareTurnSpeed()
	{
		_prepareMinTurnSpeed = Manager.Instance.Difficulty switch
		{
			0 => 0f,
			1 => Game.Random.Float( 0f, 0.5f ),
			2 => Game.Random.Float( 0f, 1f ),
			_ => 0f
		};
	}
}