things/enemies/Charger.cs

Enemy AI component for a Charger enemy. Controls spawning stats, charge attack state machine, random walking, movement/rotation while charging, animations and networked RPCs for charge events.

NetworkingFile Access
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()
	{

	}
}