things/enemies/Spitter.cs

Enemy AI component for the "Spitter" enemy. It defines movement, shooting and blink teleport behaviors, state machine transitions, animations, spawn/death effects and RPCs to broadcast visual/sound effects and projectile spawning.

NetworkingExternal Download
using System;
using Sandbox;

public class Spitter : Enemy
{
	[Property] public EnemyProjectileType ProjectileType { get; set; }

	public override EnemyType EnemyType => EnemyType.Spitter;
	public override float GetMaxHealth()
	{
		return 50f;
	}

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

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

	protected float _prepareShootTime;
	protected float _shootTime;
	protected float _shootRange;

	protected bool _isRetreating;
	protected float _personalRetreatRange;
	protected float _personalStopRetreatRange;
	private Vector2 _personalTargetOffset;

	protected float _blinkDelayTimer;
	protected float _blinkDelayMin;
	protected float _blinkDelayMax;

	/// <summary>
	/// Can blink if target is at least this close. Not the max distance to blink.
	/// </summary>
	protected float _blinkRange; 

	protected TimeSince _timeSinceBlinking;
	protected Vector2 _blinkPos;
	protected float _blinkPrepareDelay;
	protected Vector2 _blinkTargetPos;

	public override bool CanAttack => base.CanAttack && State == SpitterState.Default;
	public override bool CanMove => base.CanMove && State == SpitterState.Default;
	public override bool CanTurn => base.CanTurn && (State == SpitterState.Default || State == SpitterState.ShootPrepare || State == SpitterState.Shoot);
	protected override bool ShouldRetreatFromTarget => IsFearful || (_isRetreating && !IsAttacking && !(State == SpitterState.ShootPrepare || State == SpitterState.Shoot));

	protected enum SpitterState
	{
		Default, 
		ShootPrepare,
		Shoot,
		ShootFinish,
		BlinkPrepare,
		Blink,
		BlinkFinish,
	}

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

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

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

		PushStrength = 6000f;
		Weight = 1.2f;

		_personalSpeedScale = Game.Random.Float( 1f, 1.1f );
		_personalSpeedFreq = Game.Random.Float( 9f, 11f );
		
		if ( IsProxy )
			return;

		AggroRange = 70f;
		DetectTargetRange = 500f;
		LoseTargetRange = 1100f;
		LoseTargetTime = 5f;
		MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 9f, 10f, 11f );
		DamageTargetDelay = 0.75f;
		_personalTurnSpeed = Game.Random.Float( 4f, 7f );
		Acceleration = Utils.Select( Manager.Instance.Difficulty, 180f, 200f, 200f );
		AccelerationAttacking = Utils.Select( Manager.Instance.Difficulty, 230f, 250f, 250f );
		Deceleration = 2.2f;
		DecelerationAttacking = 1.95f;

		_shootDelayMin = 2f;
		_shootDelayMax = 7f;
		_shootRange = Utils.Select( Manager.Instance.Difficulty, 480f, 500f, 650f );
		_prepareShootTime = 0.9f;
		_shootTime = 0.6f;

		_blinkDelayMin = 5f;
		_blinkDelayMax = 10f;
		_blinkPrepareDelay = 0.9f;
		_blinkRange = 700f;

		_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax );
		_blinkDelayTimer = Game.Random.Float( _blinkDelayMin, _blinkDelayMax );

		_personalRetreatRange = Game.Random.Float( 185f, 250f );
		_personalStopRetreatRange = Game.Random.Float( 300f, 370f );
		_personalTargetOffset = new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * Game.Random.Float( 150f, 450f );
	}

	//public void SetProjectileType( EnemyProjectileType projectileType )
	//{
	//	ProjectileType = projectileType;

	//	GameObject particleObject = null;
	//	switch(projectileType)
	//	{
	//		case EnemyProjectileType.Normal:
	//		default:
	//			break;
	//		case EnemyProjectileType.Acid:
	//			particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_acid.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
	//			break;
	//		case EnemyProjectileType.Burning:
	//			particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_fire.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
	//			break;
	//		case EnemyProjectileType.Freezing:
	//			particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_freeze.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
	//			break;
	//		case EnemyProjectileType.Poison:
	//			particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_poison.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
	//			break;
	//		case EnemyProjectileType.Curse:
	//			particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_curse.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
	//			break;
	//	}
	//	if ( particleObject.IsValid() )
	//		particleObject.LocalPosition = new Vector3( 0f, 0f, 40f );
	//}

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

		//Gizmo.Draw.Color = Color.White.WithAlpha(0.1f);
		//Gizmo.Draw.Text( $"\n\n{ProjectileType}", new global::Transform( WorldPosition ) );

		if ( Manager.Instance.IsGameOver )
			return;

		if ( IsProxy )
			return;

		if ( !IsStunned && !IsDying )
			HandleState();
	}

	protected void HandleState()
	{
		switch ( State )
		{
			case SpitterState.Default:
				if ( HasTarget )
				{
					var distSqr = (TargetUnit.Position2D - Position2D).LengthSquared;

					if ( _isRetreating && distSqr > MathF.Pow( _personalStopRetreatRange, 2f ) )
						_isRetreating = false;
					else if ( !_isRetreating && distSqr < MathF.Pow( _personalRetreatRange, 2f ) )
						_isRetreating = true;
				}

				if ( TargetUnit.IsValid() && !IsInTheAir && _timeSinceChangeState > 0.5f )
				{
					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 ) )
						{
							BeginShooting();
							SetState( SpitterState.ShootPrepare );
						}
					}

					if ( targetDistSqr < MathF.Pow( _blinkRange, 2f ) )
					{
						_blinkDelayTimer -= Time.Delta * TimeScale;
						if ( _blinkDelayTimer < 0f )
							SetState( SpitterState.BlinkPrepare );
					}
				}

				break;
			case SpitterState.ShootPrepare:
				if ( _timeSinceChangeState > _prepareShootTime )
					SetState( SpitterState.Shoot );

				break;
			case SpitterState.Shoot:
				HandleStateShoot();

				break;

			case SpitterState.BlinkPrepare:
				var blinkPrepareScale = Vector3.Lerp( new Vector3( 1f ), new Vector3( 1f, 1f, 1.3f ), Utils.Map( _timeSinceChangeState, 0f, _blinkPrepareDelay, 0f, 1f, EasingType.ExpoIn ) );
				WorldScale = blinkPrepareScale;

				if ( _timeSinceChangeState > _blinkPrepareDelay )
					SetState( SpitterState.Blink );

				break;

			case SpitterState.Blink:
				var blinkFinishScale = Vector3.Lerp( new Vector3( 1f, 1f, 1.3f ), new Vector3( 1f ), Utils.Map( _timeSinceChangeState, 0f, 0.5f, 0f, 1f, EasingType.QuadOut ) );
				WorldScale = blinkFinishScale;

				if ( _timeSinceChangeState > 0.5f )
					SetState( SpitterState.BlinkFinish );

				break;
		}
	}

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

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

				break;
			case SpitterState.ShootPrepare:
				SetStateShootPrepare();

				break;
			case SpitterState.Shoot:
				SetStateShoot();

				break;
			case SpitterState.ShootFinish:
				Velocity = Vector2.Zero;

				SetState( SpitterState.Default );

				break;
			case SpitterState.BlinkPrepare:
				StartBlinkingRpc();

				_timeSinceBlinking = 0f;
				_blinkDelayTimer = Game.Random.Float( _blinkDelayMin, _blinkDelayMax );

				break;
			case SpitterState.Blink:
				var blinkPos = GetBlinkTargetPos();

				BlinkRpc( WorldTransform, blinkPos );

				break;

			case SpitterState.BlinkFinish:
				SetState( SpitterState.Default );
				break;
		}
	}

	protected virtual void BeginShooting()
	{

	}

	protected virtual void SetStateShootPrepare()
	{
		StartShootingRpc();

		_isRetreating = false;

		_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax );
		_personalTargetOffset = new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * Game.Random.Float( 150f, 450f );
	}

	protected virtual void SetStateShoot()
	{
		ShootRpc();
	}

	protected virtual void HandleStateShoot()
	{
		if ( _timeSinceChangeState > _shootTime )
			SetState( SpitterState.ShootFinish );
	}

	protected virtual Vector2 GetBlinkTargetPos()
	{
		Vector2 blinkPos = TargetUnit.IsValid()
			? TargetUnit.Position2D + TargetUnit.Velocity * Game.Random.Float( 0f, 1.5f ) + Utils.GetRandomVector() * Game.Random.Float( 200f, 400f )
			: Position2D + Utils.GetRandomVector() * Game.Random.Float(200f, 400f);

		return Manager.Instance.ClampPosToBounds( blinkPos );
	}

	protected override float GetMoveSpeedFactor()
	{
		var leftFootStart = 0.35f;
		var leftFootEnd = 0.55f;

		var rightFootStart = 0.55f;
		var rightFootEnd = 0.80f;

		var leftFootStart2 = 0.80f;
		var leftFootEnd2 = 1.0f;

		var rightFootStart2 = 0f;
		var rightFootEnd2 = 0.35f;

		var progress = ModelRenderer.SceneModel.CurrentSequence.TimeNormalized;

		if ( progress > leftFootStart && progress < leftFootEnd )
			return Utils.Map( progress, leftFootStart, leftFootEnd, 0f, 1f, EasingType.Linear );
		else if ( progress > leftFootStart2 && progress < leftFootEnd2 )
			return Utils.Map( progress, leftFootStart2, leftFootEnd2, 0f, 1f, EasingType.Linear );
		else if ( progress > rightFootStart && progress < rightFootEnd )
			return Utils.Map( progress, rightFootStart, rightFootEnd, 0f, 1f, EasingType.Linear );
		else if ( progress > rightFootStart2 && progress < rightFootEnd2 )
			return Utils.Map( progress, rightFootStart2, rightFootEnd2, 0f, 1f, EasingType.Linear );

		return 0f;
	}

	protected override Vector2 GetTargetOffset()
	{
		var dist = (TargetUnit.Position2D - Position2D).Length;

		var offset = TargetUnit.Velocity * (0.5f + Utils.FastSin( TimeSinceSpawn * 3f ) * 0.5f) * dist * 0.012f;

		// don't lead shots if target is very close and heading toward us		
		if( dist < 110f )
		{
			var dot = Vector2.Dot( (Position2D - TargetUnit.Position2D).Normal, TargetUnit.Velocity.Normal );
			if ( dot > 0.65f )
				offset = Vector2.Zero;
		}

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

		return offset;
	}

	protected virtual void PlayShootAnim()
	{
		SetAnim( "Shoot", forceRestart: true );

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

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

		_isRetreating = false;
	}

	[Rpc.Broadcast]
	protected void StartShootingRpc()
	{
		StartShooting();
	}

	protected virtual void StartShooting()
	{
		CanAnimate = false;
		SetPlaybackRate( 1f );

		PlayShootAnim();

		Manager.Instance.PlaySfxNearby( "spitter.prepare", Position2D, pitch: Game.Random.Float( 1f, 1.1f ), volume: 0.6f, maxDist: 400f );
	}

	[Rpc.Broadcast]
	protected void ShootRpc()
	{
		Shoot();
	}

	protected virtual void Shoot()
	{
		Manager.Instance.PlaySfxNearby( "spitter.shoot", Position2D, pitch: Game.Random.Float( 1f, 1.1f ), volume: 0.85f, maxDist: 450f );

		if ( IsProxy )
			return;

		var dir = (Vector2)WorldRotation.Forward;
		var pos = Position2D + dir * 45f;
		Manager.Instance.SpawnEnemyProjectile( pos, dir, shooter: this, enemyType: this.EnemyType, startVel: 150f, projectileType: ProjectileType );
	}

	[Rpc.Broadcast]
	public void StartBlinkingRpc()
	{
		StartBlinking();
	}

	public void StartBlinking()
	{
		//SS2Game.PlaySfx("spitter.prepare", Position, pitch: Game.Random.Float(1f, 1.1f), volume: 0.8f);
		//SS2Game.SpawnRing( pos, 25f, BLINK_PREPARE_DELAY, new Color( 0.7f, 0f, 1f, 0.9f ) );

		CanAnimate = false;

		SetAnim( "Blink" );

		SetPlaybackRate( 0.7f );
	}

	[Rpc.Broadcast(NetFlags.Reliable)]
	protected void BlinkRpc( Transform sourceTransform, Vector2 pos )
	{
		Blink( sourceTransform, pos );
	}

	protected virtual void Blink( Transform sourceTransform, Vector2 pos )
	{
		var blinkEffectObj = GameObject.Clone( "prefabs/effects/spitter_blink_effect.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform(sourceTransform.Position, sourceTransform.Rotation, sourceTransform.Scale * SpawnScale.x) } );
		if( blinkEffectObj.IsValid() )
		{
			SpitterBlinkEffect blinkEffect = blinkEffectObj.GetComponent<SpitterBlinkEffect>();
			blinkEffect.ModelRenderer.SceneModel.CurrentSequence.Name = "Blink";
			blinkEffect.AnimTime = ModelRenderer.SceneModel.CurrentSequence.Time;
		}

		//GameObject.Clone( "prefabs/effects/spitter_blink_particles.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( 30f ) ) } );
		//GameObject.Clone( "prefabs/effects/spitter_blink_particles.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( pos.x, pos.y, 30f ) ) } );

		var numCloudsStart = Game.Random.Int( 5, 9 );
		for ( int i = 0; i < numCloudsStart; i++ )
		{
			var cloudPos = WorldPosition.WithZ( Game.Random.Float( 7f, 12f ) );
			var dir = Utils.GetRandomVector();
			var deceleration = 4f;
			Manager.Instance.SpawnCloud( cloudPos + (Vector3)dir * Game.Random.Float( 30f, 45f ), velocity: -dir * Game.Random.Float( 90f, 155f ), deceleration, lifetime: Game.Random.Float( 0.5f, 0.8f ), bright: true );
		}

		Manager.Instance.PlaySfxNearby( "blink.start", Position2D, pitch: Game.Random.Float( 1.1f, 1.2f ), volume: 0.3f, maxDist: 400f );
		Manager.Instance.PlaySfxNearby( "blink.end", pos, pitch: Game.Random.Float( 1.1f, 1.2f ), volume: 0.5f, maxDist: 400f );

		SetPlaybackRate( 1.2f );

		WorldPosition = new Vector3( pos.x, pos.y, 0f );
		Transform.ClearInterpolation();

		var numCloudsEnd = Game.Random.Int( 5, 9 );
		for (int i = 0; i < numCloudsEnd; i++)
		{
			var cloudPos = new Vector3( pos.x, pos.y, Game.Random.Float( 7f, 12f ) );
			var dir = Utils.GetRandomVector();
			var deceleration = 4f;
			Manager.Instance.SpawnCloud( cloudPos + (Vector3)dir * Game.Random.Float( 0.1f, 5f ), velocity: dir * Game.Random.Float( 160f, 300f ), deceleration, lifetime: Game.Random.Float( 0.5f, 2f ), bright: true );
		}
	}

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

		PlayFlinchAnim();

		SetState( SpitterState.Default );
	}

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

		switch ( ProjectileType )
		{
			case EnemyProjectileType.Normal:
			default:
				break;
			case EnemyProjectileType.Acid:
				Manager.Instance.PlaySfxNearbyRpc( "puddle_splat", Position2D, pitch: Game.Random.Float( 1.05f, 1.1f ), volume: 1.2f, maxDist: 350f );

				var acidDmg = Utils.Select( Manager.Instance.Difficulty, 3f, 5f, 7f );
				Manager.Instance.SpawnAcidPuddle( Position2D, lifetime: Game.Random.Float( 8f, 10f ), acidDmg, scale: Game.Random.Float( 0.8f, 1.1f ), Color.Yellow, new Color( 0.5f, 0.5f, 0f ), playerSource: null, enemySource: null, enemyType: this.EnemyType );
				break;
			case EnemyProjectileType.Fire:
				var fireDmg = Utils.Select( Manager.Instance.Difficulty, 3f, 5f, 6f );
				var spreadChance = Utils.Select( Manager.Instance.Difficulty, 0.25f, 0.35f, 0.45f );
				Manager.Instance.SpawnFireGroundRpc( Position2D, player: null, enemySource: null, enemyType: this.EnemyType, damage: fireDmg, lifetime: Game.Random.Float( 10f, 12f ), spreadChance: spreadChance, canStack: false, scale: 1f, colorA: Color.Magenta, colorB: Color.Red, hurtPlayers: true, hurtEnemies: false );
				// todo: sfx
				break;
			case EnemyProjectileType.Freeze:
				break;
			case EnemyProjectileType.Poison:
				break;
		}
	}

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

		WorldScale = new Vector3( 1f );
	}

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

		base.Jump( targetPos, height, lifetime );
	}

	protected override void SpawnGibs( Vector2 dir, float force, DamageType damageType )
	{
		base.SpawnGibs( dir, force, damageType );

		// todo: don't spawn head gib if shooting, or might have 2 heads
	}
}