things/enemies/Charger.cs

Enemy AI class for a Charger enemy, derived from Enemy. It manages states (default, prepare, charge, finish), charging movement and timers, random walking when idle, animations and networked RPCs for charge actions and state transitions.

NetworkingExternal Download
using System;
using Sandbox;

public class Charger : Enemy
{
	public override EnemyType EnemyType => EnemyType.Charger;
	public override float MeleeForce => 40f;
	public override float MeleeRagdollForce => 3f;
	public override float MeleeUpwardForceAmount => State == ChargerState.Charge
		? Game.Random.Float( 0.5f, 2f )
		: Game.Random.Float( 0f, 1f );

	public override float GetMaxHealth()
	{
		switch ( Manager.Instance.Difficulty )
		{
			case 0: default: return 82f;
			case 1: return 87f;
			case 2: return 90f;
		}
	}

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

	public override int ExtraDeathBloodSprayAmount => 15;

	protected float _chargeDelayTimer;
	protected float _chargeDelayMin;
	protected float _chargeDelayMax;

	protected float _prepareTimer;
	protected float _chargeTimer;
	protected float _chargeTime;
	protected float _chargeTimeMin;
	protected float _chargeTimeMax;

	protected Vector2 _chargeDir;
	protected Vector2 _chargeVel;
	protected TimeSince _timeSinceChargeCloud;
	protected float _personalChargeRange;
	protected float _chargeSpeed;

	public bool IsFinishingCharging { get; protected set; }
	protected TimeSince _timeSinceCharging;
	//protected bool _playingChargeEndAnim;

	protected float _chargeRotateSpeed;

	protected float _baseWeight;
	protected float _chargeWeight;

	protected float _chargeVelMax;

	protected virtual bool UseRandomWalk => true;

	// Random walk configuration
	protected float _randomWalkIntervalMin = 7f;
	protected float _randomWalkIntervalMax = 30f;
	protected float _randomWalkDurationMin = 2f;
	protected float _randomWalkDurationMax = 9f;

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

	public override bool CanTurn => base.CanTurn && !IsDying && !(State == ChargerState.ChargePrepare || State == ChargerState.ChargeFinish);
	public override bool CanMove => base.CanMove && State == ChargerState.Default;// !(State == ChargerState.ChargePrepare || State == ChargerState.ChargeFinish);
	public override bool CanAttack => base.CanAttack && !(State == ChargerState.ChargePrepare || State == ChargerState.Charge);
	public override bool CanDamageByTouch => !IsDying && !IsStunned && !IsInTheAir && ( IsAttacking || State == ChargerState.Charge ) && State != ChargerState.ChargeFinish;

	public override float ParticleYPosOverride => 0.75f;
	public override float StunParticleYPosOverride => 1.05f;

	protected enum ChargerState
	{
		Default,
		ChargePrepare,
		Charge,
		ChargeFinish,
	}

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

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

		CoinValueMin = 4;
		CoinValueMax = 6;
		CoinChance = 1f;

		PushStrength = 12000f;
		Weight = _baseWeight = 2.2f;
		_chargeWeight = 3f;

		_personalSpeedScale = Game.Random.Float( 0.9f, 1.1f );
		_personalSpeedFreq = Game.Random.Float( 9f, 11f );

		if ( IsProxy )
			return;

		AggroRange = 90f;
		DetectTargetRange = 500f;
		LoseTargetRange = 900f;
		LoseTargetTime = 7f;
		MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 6f, 9f, 12f );
		DamageTargetDelay = 1f;
		Acceleration = Utils.Select( Manager.Instance.Difficulty, 200f, 220f, 225f );
		AccelerationAttacking = Utils.Select( Manager.Instance.Difficulty, 230f, 270f, 275f );
		Deceleration = 2.5f;
		DecelerationAttacking = 2.3f;

		_personalTurnSpeed = Game.Random.Float( 4f, 7f );
		_personalChargeRange = Game.Random.Float( 280f, 420f );

		_chargeTimeMin = 1.8f;
		_chargeTimeMax = 2.5f;
		_chargeDelayMin = 2f;
		_chargeDelayMax = 6f;
		_chargeDelayTimer = Game.Random.Float( _chargeDelayMin, _chargeDelayMax );
		_chargeRotateSpeed = 7f;
		_chargeSpeed = Utils.Select( Manager.Instance.Difficulty, 205f, 225f, 230f );
		_chargeVelMax = Utils.Select( Manager.Instance.Difficulty, 300f, 400f, 400f );

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

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

		if ( Manager.Instance.IsGameOver )
			return;

		if ( IsDying )
			return;

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"_isRandomWalking: {_isRandomWalking}\n_randomWalkTimer: {_randomWalkTimer}\n_randomWalkDuration: {_randomWalkDuration}", new global::Transform( WorldPosition ) );
		//Gizmo.Draw.Text( $"State: {State}\nCurrentSequence: {ModelRenderer.SceneModel.CurrentSequence.Name}\nprogress: {ModelRenderer.SceneModel.CurrentSequence.TimeNormalized.ToString( "N2" )}\nspeed: {GetMoveSpeedFactor().ToString( "N2" )}", new global::Transform( WorldPosition ) );

		if ( State == ChargerState.Charge )
		{
			//if ( _timeSinceChargeCloud > 0.1f * (1f / TimeScale) )
			if ( _timeSinceChargeCloud > 0.1f )
			{
				var pos = WorldPosition.WithZ( 10f );
				GameObject.Clone( "prefabs/effects/cloud.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( pos ) } );
				_timeSinceChargeCloud = 0f;
			}
		}

		if ( IsProxy )
			return;

		if ( !IsStunned )
			HandleState();
	}

	protected void HandleState()
	{
		switch ( State )
		{
			case ChargerState.Default:
				if ( TargetUnit.IsValid() && !IsInTheAir && _timeSinceChangeState > 0.5f && !IsStunned )
				{
					var targetDistSqr = (TargetUnit.Position2D - Position2D).LengthSquared;
					if (targetDistSqr < MathF.Pow( _personalChargeRange, 2f ) )
					{
						_chargeDelayTimer -= Time.Delta * TimeScale;
						if ( _chargeDelayTimer < 0f )
							SetState( ChargerState.ChargePrepare );
					}
				}

				// Handle random walk behavior
				if ( UseRandomWalk )
				{
					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 ChargerState.ChargePrepare:
				if ( _timeSinceChangeState > 0.75f )
					SetState( ChargerState.Charge );

				break;
			case ChargerState.Charge:
				// todo: charger gets too much speed now?

				_chargeVel += _chargeDir * _chargeSpeed * Utils.MapReturn( _timeSinceChangeState, 0f, _chargeTime, 0.5f, 1f, EasingType.QuadOut ) * Time.Delta * Manager.Instance.GlobalMovespeedModifier;

				if ( _chargeVel.LengthSquared > _chargeVelMax * _chargeVelMax )
					_chargeVel = _chargeVel.Normal * _chargeVelMax;

				WorldPosition += (Vector3)(_chargeVel + Velocity) * Time.Delta;
				Velocity *= Math.Max( 1f - Time.Delta * 2.5f * Manager.Instance.GlobalFrictionModifier, 0f );

				if ( Manager.Instance.IsWindActive )
					Velocity += Manager.Instance.GlobalWindForce * Time.Delta;

				//SetPlaybackRate( Utils.MapReturn( _chargeTimer, _chargeTime, 0f, 1f, 3f, EasingType.QuadOut ) );

				if ( _timeSinceChangeState > _chargeTime )
					SetState( ChargerState.ChargeFinish );

				break;
			case ChargerState.ChargeFinish:
				//if ( _timeSinceChangeState > 0.5f )
					SetState( ChargerState.Default );

			break;
		}
	}

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

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

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

				break;
			case ChargerState.ChargePrepare:
				_isRandomWalking = false; // Cancel random walk when preparing to charge

				_chargeDelayTimer = Game.Random.Float( _chargeDelayMin, _chargeDelayMax );

				PrepareToChargeRpc();

				Velocity *= 0.7f;

				break;
			case ChargerState.Charge:
				ChargeRpc();

				break;
			case ChargerState.ChargeFinish:
				StopChargingRpc();

				Velocity = _chargeVel;

				break;
		}
	}

	protected override void HandleRotation()
	{
		if ( State == ChargerState.Charge )
		{
			if( !HitstopActive )
				WorldRotation = Rotation.Lerp( WorldRotation, Rotation.From( new Angles( 0f, -Utils.GetAngleDegreesFromVector( _chargeDir ), 0f ) ), _chargeRotateSpeed * Time.Delta * TimeScale );
		}
		else if ( State == ChargerState.Default && _isRandomWalking && !IsAttacking && !ShouldRetreatFromTarget )
		{
			var targetFacingDir = ((Vector3)_randomWalkDirection).WithZ( 0f );
			WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( targetFacingDir ), _personalTurnSpeed * Time.Delta * TimeScale );
		}
		else
		{
			base.HandleRotation();
		}
	}

	//protected override void HandleMovement()
	//{
	//	if ( State != ChargerState.Default )
	//		return;

	//	base.HandleMovement();
	//}

	protected override float GetMoveSpeedFactor()
	{
		var progress = ModelRenderer.SceneModel.CurrentSequence.TimeNormalized;
		return Charger.GetChargerMoveSpeedFactor( progress, IsAttacking );
	}

	public static float GetChargerMoveSpeedFactor( float animProgress, bool isAttacking )
	{
		var leftFootStart = 0.70f;
		var leftFootEnd = 0.2f;
		var rightFootStart = isAttacking ? 0.3f : 0.22f;
		var rightFootEnd = isAttacking ? 0.7f : 0.64f;

		if ( animProgress > leftFootStart || animProgress < leftFootEnd )
		{
			var totalProgress = 1f + leftFootEnd;
			var offsetProgress = animProgress < leftFootEnd
				? 1f + animProgress
				: animProgress;

			return Utils.Map( offsetProgress, leftFootStart, totalProgress, 0f, 1f, EasingType.QuadOut );
		}
		else if ( animProgress > rightFootStart && animProgress < rightFootEnd )
		{
			return Utils.Map( animProgress, rightFootStart, rightFootEnd, 0f, 1f, EasingType.QuadOut );
		}

		return 0f;
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void PrepareToChargeRpc()
	{
		Manager.Instance.PlaySfxNearby( "enemy.roar.prepare", Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 0.95f, maxDist: 550f );

		SetAnim( "ChargePrepare" );
		SetPlaybackRate( 1f );

		CanAnimate = false;
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void ChargeRpc()
	{
		Charge();
	}

	protected virtual void Charge()
	{
		Manager.Instance.PlaySfxNearby( "enemy.roar", Position2D, pitch: Game.Random.Float( 0.925f, 1.075f ), volume: 0.85f, maxDist: 550f );

		SetAnim( "Charge" );
		SetPlaybackRate( 1f );

		_timeSinceChargeCloud = 0f;

		if ( IsProxy )
			return;

		Player closestPlayer = Manager.Instance.GetClosestPlayer( Position2D );
		if ( !closestPlayer.IsValid() )
			return;

		var targetPos = closestPlayer.Position2D + closestPlayer.Velocity * Game.Random.Float( 0.2f, 1f ) + new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * 100f;
		Vector2 targetDir = (targetPos - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
			? (targetPos - Position2D).Normal
			: Utils.GetRandomVector();

		_chargeDir = Utils.RotateVector( targetDir, Game.Random.Float( -10f, 10f ) );

		_chargeTime = Game.Random.Float( _chargeTimeMin, _chargeTimeMax );
		_chargeVel = Vector2.Zero;

		//PlaybackRate = 3f;
		Weight = _chargeWeight;
	}

	[Rpc.Broadcast( NetFlags.Reliable )]
	public void StopChargingRpc()
	{
		//SetAnim( "ChargePrepare" );
		//SetPlaybackRate( 1.6f );
		IsFinishingCharging = true;
		_timeSinceCharging = 0f;
		//_playingChargeEndAnim = true;
	}

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

		if ( State == ChargerState.Charge )
		{
			if ( direction == Direction.Left )
			{
				_chargeVel = new Vector2( Math.Abs( _chargeVel.x ), _chargeVel.y );
				_chargeDir = new Vector2( Math.Abs( _chargeDir.x ), _chargeDir.y );
			}
			else if ( direction == Direction.Right )
			{
				_chargeVel = new Vector2( -Math.Abs( _chargeVel.x ), _chargeVel.y );
				_chargeDir = new Vector2( -Math.Abs( _chargeDir.x ), _chargeDir.y );
			}
			else if ( direction == Direction.Down )
			{
				_chargeVel = new Vector2( _chargeVel.x, Math.Abs( _chargeVel.y ) );
				_chargeDir = new Vector2( _chargeDir.x, Math.Abs( _chargeDir.y ) );
			}
			else if ( direction == Direction.Up )
			{
				_chargeVel = new Vector2( _chargeVel.x, -Math.Abs( _chargeVel.y ) );
				_chargeDir = new Vector2( _chargeDir.x, -Math.Abs( _chargeDir.y ) );
			}

			_chargeVel *= Utils.Select( Manager.Instance.Difficulty, 0.33f, 0.5f, 0.9f );

			// todo: sfx
		}
		else if ( State == ChargerState.Default && _isRandomWalking )
		{
			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 ) );
		}
	}

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

		PlayFlinchAnim();

		SetState( ChargerState.Default );
	}

	protected override void Jump( Vector2 targetPos, float height, float lifetime )
	{
		SetState( ChargerState.Default );

		base.Jump( targetPos, height, lifetime );

		// todo: if jump while charging, weird anim speed
		// todo: also can be charging way too slow, maybe related
	}

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

		SetState( ChargerState.Default );
	}

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

		if ( IsProxy )
			return;

		Weight = _baseWeight;
	}

	public override void Celebrate( bool victory )
	{
		SetState( ChargerState.Default );

		base.Celebrate( victory );
	}

	protected override void PlayFlinchAnim()
	{

	}
}