things/enemies/Snaker.cs

Enemy subclass for a snake-like enemy called Snaker. It handles movement, leader/follower chaining (segments), shooting state machine (prepare, shoot, finish), projectile spawning, animations and reactions to death/stun and out-of-bounds events.

Networking
using System;
using Sandbox;

public class Snaker : Enemy
{
	public override EnemyType EnemyType => EnemyType.Snaker;
	public override float GetMaxHealth()
	{
		return 65f;
	}

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

	protected float _shootDelayTimer;
	protected float _shootDelayMin;
	protected float _shootDelayMax;

	protected float _shootRange;

	public override bool CanMove => base.CanMove && !HasLeader;
	protected override bool ShouldRetreatFromTarget => IsFearful && !HasLeader && !IsAttacking;

	public Snaker Leader { get; set; }
	public Snaker Follower { get; set; }

	private Vector2 _moveDir;

	public bool HasLeader => Leader.IsValid();

	private TimeSince _timeSinceLeaderTargetChanged;
	private float _targetDelay;

	public int NumSegments { get; set; }
	public int NumSegmentsStart { get; set; }

	protected enum SnakerState
	{
		Default,
		ShootPrepare,
		Shoot,
		ShootFinish,
	}

	protected SnakerState State { get; private set; } = SnakerState.Default;

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

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

		PushStrength = 7000f;
		Weight = 1.4f;

		_personalSpeedScale = Game.Random.Float( 1.2f, 1.4f );
		_personalSpeedFreq = Game.Random.Float( 9f, 11f );

		if ( IsProxy )
			return;

		AggroRange = 50f;
		DetectTargetRange = 800f;
		LoseTargetRange = 1300f;
		LoseTargetTime = 5f;
		MeleeDamage = 11f;
		DamageTargetDelay = 0.6f;
		_personalTurnSpeed = Game.Random.Float( 7f, 8f );
		Acceleration = AccelerationAttacking = 160f;
		Deceleration = DecelerationAttacking = 1.9f;

		_shootDelayMin = 6.5f;
		_shootDelayMax = 8f;
		_shootRange = 500f;

		_shootDelayTimer = Game.Random.Float( 1f, 10f );

		_moveDir = Utils.GetRandomVector();
		_timeSinceLeaderTargetChanged = 0f;
		_targetDelay = Game.Random.Float( 2f, 5f );
	}

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

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

		//if ( IsBlinking )
		//{
		//	Gizmo.Draw.Color = Color.Cyan;
		//	Gizmo.Draw.Line( WorldPosition, _blinkPos );
		//}

		if ( Manager.Instance.IsGameOver )
			return;

		if ( IsProxy )
			return;

		if ( !IsStunned )
			HandleState();

		if ( IsInTheAir )
			return;

		bool hasLeader = Leader.IsValid();

		if ( !hasLeader && _timeSinceLeaderTargetChanged > _targetDelay && TargetUnit.IsValid() )
		{
			var targetPos = TargetUnit.Position2D + TargetUnit.Velocity * Game.Random.Float( 0f, 2f ) + Utils.GetRandomVector() * Game.Random.Float(0f, 50f);
			_moveDir = (targetPos - Position2D).Normal;
			_timeSinceLeaderTargetChanged = 0f;
			_targetDelay = Game.Random.Float( 1f, 10f ) * Utils.Map( NumSegments, NumSegmentsStart, 1, 1f, 0.1f, EasingType.QuadIn );
		}

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Line( WorldPosition, TargetPos );

		if( hasLeader )
			Position2D = Utils.DynamicEaseTo( Position2D, Leader.Position2D, Utils.Map( (Leader.Position2D - Position2D).LengthSquared, MathF.Pow( 250f, 2f ), 0f, 0.5f, 0f, EasingType.QuadIn ), Time.Delta );

		AnimSpeedModifier = HasLeader
			? Utils.Map( (Leader.Position2D - Position2D).LengthSquared, MathF.Pow( 250f, 2f ), Radius * Radius, 15f, 0.1f, EasingType.QuadIn )
			: 1.5f;

		AnimSpeedModifier *= Utils.Map( NumSegments, NumSegmentsStart, 1, 0.8f, 1.5f );
	}

	protected void HandleState()
	{
		switch ( State )
		{
			case SnakerState.Default:
				if ( TargetUnit.IsValid() && !IsInTheAir )
				{
					var targetDistSqr = (TargetUnit.Position2D - Position2D).LengthSquared;

					if ( targetDistSqr < MathF.Pow( _shootRange, 2f ) )
					{
						_shootDelayTimer -= Time.Delta * TimeScale;
						if ( _shootDelayTimer < 0f && targetDistSqr < MathF.Pow( _shootRange * 0.85f, 2f ) )
							SetState( SnakerState.ShootPrepare );
					}
				}

				break;
			case SnakerState.ShootPrepare:
				if ( _timeSinceChangeState > 1f )
					SetState( SnakerState.Shoot );

				break;
			case SnakerState.Shoot:
				if ( _timeSinceChangeState > 0.6f )
					SetState( SnakerState.ShootFinish );
				break;
		}
	}

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

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

				break;
			case SnakerState.ShootPrepare:
				StartShootingRpc();

				_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax ) * Utils.Map( NumSegments, NumSegmentsStart, 1, 2f, 0.25f, EasingType.SineIn );

				break;
			case SnakerState.Shoot:
				if( TargetUnit.IsValid() )
				{
					ShootRpc();

					var targetPos = TargetUnit.Position2D + TargetUnit.Velocity * Game.Random.Float( 0f, 2f ) + Utils.GetRandomVector() * Game.Random.Float( 0f, 50f );
					var dir = (targetPos - Position2D).Normal;
					var pos = Position2D + dir * 20f;
					Manager.Instance.SpawnEnemyProjectile( pos, dir, shooter: this, enemyType: this.EnemyType, startVel: 150f );
				}

				break;
			case SnakerState.ShootFinish:
				SetState( SnakerState.Default );

				break;
		}
	}

	protected override void HandleRotation()
	{
		var facingPos = HasLeader
			? Leader.Position2D
			: Position2D + _moveDir * 100f;

		var targetFacingDir = ((Vector3)facingPos - WorldPosition).Normal.WithZ( 0f ); // todo: optimize?

		if ( ShouldRetreatFromTarget )
			targetFacingDir *= -1f;

		WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( targetFacingDir ), _personalTurnSpeed * Time.Delta * TimeScale );
	}

	protected override float GetMoveSpeedFactor()
	{
		return (0.8f + Utils.FastSin( MoveTimeOffset + Time.Now * _personalSpeedFreq ) * 0.2f) * _personalSpeedScale;
	}

	//protected override Vector2 GetTargetOffset()
	//{
	//	var offset = TargetUnit.Velocity * (0.5f + Utils.FastSin( TimeSinceSpawn * 3f ) * 0.5f) * (TargetUnit.Position2D - Position2D).Length * 0.012f;

	//	if ( !IsShooting && !_isRetreating )
	//		offset += _personalTargetOffset + new Vector2( Utils.FastSin( TimeSinceSpawn * 0.7f ), Utils.FastSin( TimeSinceSpawn * 1.1f ) ) * 50f;

	//	return offset;
	//}

	[Rpc.Broadcast]
	protected void StartShootingRpc()
	{
		//CanAnimate = false;

		//PlayShootAnim();

		Manager.Instance.PlaySfxNearby( "spitter.prepare", Position2D, pitch: Game.Random.Float( 1.4f, 1.5f ), volume: 0.2f, maxDist: 200f );
	}

	//protected virtual void PlayShootAnim()
	//{
	//	SetAnim( Game.Random.Float( 0f, 1f ) < 0.5f ? "HoldItem_RH_Throw_Normal" : "HoldItem_LH_Throw_Normal" );
	//	SetPlaybackRate( 0.5f );
	//}

	[Rpc.Broadcast]
	protected void ShootRpc()
	{
		Manager.Instance.PlaySfxNearby( "spitter.shoot", Position2D, pitch: Game.Random.Float( 1.4f, 1.5f ), volume: 0.6f, maxDist: 350f );
	}

	public override void Die( Vector2 dir, float force, Player player, DamageType damageType )
	{
		base.Die( dir, force, player, damageType );

		if ( IsProxy )
			return;

		if ( Follower.IsValid() && Leader.IsValid() )
			Follower.AssignLeader( Leader );

		if ( Leader.IsValid() )
			Leader.NotifyDeathAhead();

		if ( Follower.IsValid() )
			Follower.NotifyDeathBehind();
	}

	public void AssignLeader( Snaker other )
	{
		Leader = other;
		other.AssignFollower( this );
	}

	public void AssignFollower( Snaker other )
	{
		Follower = other;
	}

	public void NotifyDeathAhead()
	{
		NotifyDeath();

		if ( Leader.IsValid() )
			Leader.NotifyDeathAhead();
	}

	public void NotifyDeathBehind()
	{
		NotifyDeath();

		if ( Follower.IsValid() )
			Follower.NotifyDeathBehind();
	}

	public void NotifyDeath()
	{
		NumSegments--;

		Acceleration = AccelerationAttacking = Utils.Map( NumSegments, NumSegmentsStart, 1, 160f, 240f, EasingType.SineIn );
		Deceleration = Utils.Map( NumSegments, NumSegmentsStart, 1, 1.9f, 1.5f, EasingType.QuadIn );
		DecelerationAttacking = DecelerationAttacking * 0.9f;
	}

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

		if ( direction == Direction.Left )
			_moveDir = new Vector2( Math.Abs( _moveDir.x ), _moveDir.y );
		else if ( direction == Direction.Right )
			_moveDir = new Vector2( -Math.Abs( _moveDir.x ), _moveDir.y );
		else if ( direction == Direction.Down )
			_moveDir = new Vector2( _moveDir.x, Math.Abs( _moveDir.y ) );
		else if ( direction == Direction.Up )
			_moveDir = new Vector2( _moveDir.x, -Math.Abs( _moveDir.y ) );
	}

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

		PlayFlinchAnim();

		SetState( SnakerState.Default );
	}

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