things/enemies/Spiker.cs

Enemy subclass Spiker. Controls spawning, movement, shooting spiker head projectiles, digging underground behavior, state machine transitions, animations and networked RPCs for multiplayer visuals and sounds.

Networking
using System;
using Sandbox;

public class Spiker : Enemy
{
	public override EnemyType EnemyType => EnemyType.Spiker;
	public override float GetMaxHealth()
	{
		switch ( Manager.Instance.Difficulty )
		{
			case 0: default: return 75f;
			case 1: return 80f;
			case 2: return 80f;
		}
	}

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

	public override int ExtraDeathBloodSprayAmount => 15;

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

	protected float _shootRange;
	protected Vector2 _cracksPos;
	protected string _projectileName;
	protected Color _crackParticleColor;
	protected float _maxExtraHeadDist;

	protected float _digDelayTimer;
	protected float _digDelayMin;
	protected float _digDelayMax;
	protected float _digRange;
	protected float _digOffsetMin;
	protected float _digOffsetMax;

	protected TimeSince _timeSinceDigging;
	protected float _digTime;
	protected Vector2 _digPos;
	protected float _waitUndergroundTime;

	public override bool CanAttack => base.CanAttack && State == SpikerState.Default;
	public override bool CanMove => base.CanMove && State == SpikerState.Default;
	public override bool CanTurn => base.CanTurn && State == SpikerState.Default;
	public override bool CanBeStunned => base.CanBeStunned && !(State == SpikerState.DigStart || State == SpikerState.DigWaitUnderground );
	protected bool _cantBePushed;

	protected bool _moveClockwise;

	public override float SpawnZPos => -120f;

	protected int _numTimesShot;
	protected int _numShotsTotal;

	protected virtual bool ShouldCircleTarget => !IsAttacking || (Utils.FastSin( TimeSinceSpawn * 2f * _personalSpeedScale ) < 0.2f);

	protected enum SpikerState
	{
		Default,
		ShootStart,
		ShootSpawnCracks,
		Shoot,
		ShootReverse,
		DigStart,
		DigWaitUnderground,
		DigFinish,
	}

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

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

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

		PushStrength = 7500f;
		Weight = 1.5f;

		_personalSpeedScale = Game.Random.Float( 0.95f, 1.05f );
		_personalSpeedFreq = Game.Random.Float( 7f, 9f );
		_fullMeleeAttackAnimSpeed = 1.5f;

		_projectileName = "spiker_head";
		_crackParticleColor = new Color( 0.02f, 0.02f, 0.01f );

		if ( IsProxy )
			return;

		AggroRange = 65f;
		DetectTargetRange = 650f;
		LoseTargetRange = 950f;
		LoseTargetTime = 5f;
		MeleeDamage = Utils.Select( Manager.Instance.Difficulty, 11f, 12f, 13f );
		DamageTargetDelay = 0.75f;
		_personalTurnSpeed = Game.Random.Float( 3f, 6f );
		Acceleration = 180f;
		AccelerationAttacking = 220f;
		Deceleration = 2.3f;
		DecelerationAttacking = 2.1f;

		_shootDelayMin = 2f;
		_shootDelayMax = 7f;
		_shootRange = 500f;
		_maxExtraHeadDist = 14f;
		_shootReverseDelay = 0.4f;

		_digDelayMin = 5f;
		_digDelayMax = 10f;
		_digRange = 700f;
		_digTime = 1.5f;
		_digOffsetMin = 200f;
		_digOffsetMax = 400f;

		_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax );
		_digDelayTimer = Game.Random.Float( _digDelayMin, _digDelayMax );

		_moveClockwise = Game.Random.Int( 0, 1 ) == 0;
	}

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

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

		if ( Manager.Instance.IsGameOver )
			return;

		if ( IsProxy )
			return;

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

	protected void HandleState()
	{
		switch ( State )
		{
			case SpikerState.Default:
				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 )
						{
							_numTimesShot = 0;
							_numShotsTotal = GetNumShotsTotal();

							SetState( SpikerState.ShootStart );
						}
					}

					if ( targetDistSqr < MathF.Pow( _digRange, 2f ) )
					{
						_digDelayTimer -= Time.Delta * TimeScale;
						if ( _digDelayTimer < 0f )
							SetState( SpikerState.DigStart );
					}
				}

				break;
			case SpikerState.ShootStart:
				if ( _timeSinceChangeState > GetShootSpawnCracksDelay() )
					SetState( SpikerState.ShootSpawnCracks );

				break;
			case SpikerState.ShootSpawnCracks:
				ClearVelocity();

				if ( _timeSinceChangeState > 0.7f )
					SetState( SpikerState.Shoot );

				break;
			case SpikerState.Shoot:
				ClearVelocity();

				if( _numTimesShot >= _numShotsTotal )
				{
					if ( _timeSinceChangeState > 1.5f )
						SetState( SpikerState.ShootReverse );
				}
				else
				{
					if ( _timeSinceChangeState > 0.6f )
						SetState( SpikerState.ShootStart);
				}

				break;
			case SpikerState.ShootReverse:
				ClearVelocity();

				if ( _timeSinceChangeState > _shootReverseDelay )
					SetState( SpikerState.Default );

				break;
			case SpikerState.DigStart:
				var digTime = 1.5f;

				var zPos = Utils.Map( _timeSinceChangeState, 0f, digTime, 0f, SpawnZPos );
				WorldPosition = WorldPosition.WithZ( zPos );

				SpawnProgress = Utils.Map( _timeSinceChangeState, 0f, digTime, 1f, 0f );

				if ( _timeSinceChangeState > digTime )
					SetState( SpikerState.DigWaitUnderground);

				break;
			case SpikerState.DigWaitUnderground:
				if ( _timeSinceChangeState > _waitUndergroundTime )
					SetState( SpikerState.DigFinish );

				break;
		}
	}

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

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

				break;
			case SpikerState.ShootStart:
				_shootDelayTimer = Game.Random.Float( _shootDelayMin, _shootDelayMax );

				StartShootingRpc();

				break;
			case SpikerState.ShootSpawnCracks:
				var targetPlayer = TargetUnit.IsValid() && TargetUnit is Player ? TargetUnit as Player : Manager.Instance.GetClosestPlayer( Position2D );
				var targetPos = targetPlayer.IsValid()
					? GetShootTargetPos( targetPlayer )
					: Position2D + Utils.GetRandomVector() * Game.Random.Float( 100f, 400f );

				Manager.Instance.AvoidObstacles( ref targetPos, radius: 22f, andSpikerHeads: true );

				_cracksPos = Manager.Instance.ClampPosToBounds( targetPos, buffer: 10f );

				SpawnCracksRpc( _cracksPos );

				break;
			case SpikerState.Shoot:
				var headPos = _cracksPos;
				var player = TargetUnit.IsValid() && TargetUnit is Player ? TargetUnit as Player : Manager.Instance.GetClosestPlayer( Position2D );
				if ( player.IsValid() )
				{
					// extra distance to try to hit player
					headPos += (player.Position2D - headPos).Normal * Math.Min( (player.Position2D - headPos).Length, _maxExtraHeadDist );
				}

				//var zPos = -200f;
				var zPos = 0f;
				var headGo = GameObject.Clone( $"prefabs/{_projectileName}.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( new Vector3( headPos.x, headPos.y, zPos ), new Angles( 0f, Game.Random.Float( -60f, -120f ), 0f ) ) } );
				var head = headGo.Components.Get<SpikerHead>( true );
				head.Shooter = this;
				head.ModelRenderer.Tint = ModelRenderer.Tint;

				headGo.NetworkSpawn();

				Manager.Instance.SpawnSpikerHeadArea( headPos, 30f, 1.3f );

				_numTimesShot++;

				break;
			case SpikerState.ShootReverse:
				//EnterDefaultStateRpc();

				break;
			case SpikerState.DigStart:
				_digDelayTimer = Game.Random.Float( _digDelayMin, _digDelayMax );

				StartDiggingRpc();

				break;
			case SpikerState.DigWaitUnderground:
				_waitUndergroundTime = Game.Random.Float( 0.5f, 1f );

				break;
			case SpikerState.DigFinish:
				SetState( SpikerState.Default );

				Vector2 pos = TargetUnit.IsValid()
					? TargetUnit.Position2D + TargetUnit.Velocity * Game.Random.Float( 0f, 1.5f ) + Utils.GetRandomVector() * Game.Random.Float( _digOffsetMin, _digOffsetMax )
					: Position2D + Utils.GetRandomVector() * Game.Random.Float( _digOffsetMin, _digOffsetMax );

				FinishDiggingRpc( Manager.Instance.ClampPosToBounds( pos, buffer: 10f ) );

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

				_moveClockwise = !_moveClockwise;

				break;
		}
	}

	protected virtual float GetShootSpawnCracksDelay()
	{
		return 0.5f;
	}

	protected virtual int GetNumShotsTotal()
	{
		return 1;
	}

	void ClearVelocity()
	{
		Velocity = Vector2.Zero;
		ResetRepelVelocity();
		ExplosionVelocity = Vector2.Zero;
	}

	protected override float GetMoveSpeedFactor()
	{
		var leftFootStart = 0.2f;
		var leftFootEnd = 0.60f;
		var rightFootStart = 0.60f;
		var rightFootEnd = 1.25f;

		var progress = ModelRenderer.SceneModel.CurrentSequence.TimeNormalized;

		if ( progress > rightFootStart || progress < rightFootEnd)
		{
			var totalProgress = 1f + rightFootEnd;
			var offsetProgress = progress < rightFootEnd
				? 1f + progress
				: progress;

			return Utils.Map( offsetProgress, rightFootStart, totalProgress, 0f, 1f, EasingType.Linear );
		}
		else if ( progress > leftFootStart && progress < leftFootEnd )
		{
			return Utils.Map( progress, leftFootStart, leftFootEnd, 0f, 1f, EasingType.Linear );
		}

		return 0f;
	}

	protected override void HandleRotation()
	{
		if( !HasTarget || !ShouldCircleTarget )
		{
			base.HandleRotation();
			return;
		}

		Vector2 toTarget = TargetPos - Position2D;

		if ( (TargetPos - Position2D).LengthSquared < MathF.Pow( 600f, 2f ) )
			toTarget = Vector2.Lerp( toTarget, new Vector2( toTarget.y, -toTarget.x ) * (_moveClockwise ? -1f : 1f), Utils.Map( (TargetPos - Position2D).LengthSquared, MathF.Pow( 600f, 2f ), MathF.Pow( 200f, 2f ), 0f, 1f ) );

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

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

		if ( _timeSinceStartAttackingSfx > Game.Random.Float( 0.8f, 1.3f ) )
		{
			Manager.Instance.PlaySfxNearby( "monster.scream1", Position2D, pitch: Game.Random.Float( 1f, 1.2f ), volume: 1.3f, maxDist: 300f );
			_timeSinceStartAttackingSfx = 0f;
		}
	}

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

	protected virtual void StartShooting()
	{
		CanAnimate = false;
		SetAnim( "Shoot" );

		SetPlaybackRate( 0.7f );
	}

	protected virtual Vector2 GetShootTargetPos( Player targetPlayer )
	{
		return targetPlayer.IsValid()
			? targetPlayer.Position2D + targetPlayer.Velocity * Game.Random.Float( 0.2f, 2f ) + new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * 40f
			: Position2D + Utils.GetRandomVector() * Game.Random.Float( 100f, 400f );
	}

	[Rpc.Broadcast]
	protected void SpawnCracksRpc( Vector2 pos )
	{
		Manager.Instance.PlaySfxNearby( "spike.prepare", pos, pitch: Game.Random.Float( 1f, 1.1f ), volume: 0.85f, maxDist: 250f );

		Manager.Instance.SpawnSpikerHeadCracksRpc( pos, _crackParticleColor );

		Manager.Instance.SpawnSpikerHeadArea( pos, 30f, GetShootSpawnCracksDelay() + 0.15f );
	}

	//[Rpc.Broadcast]
	//protected void ReverseShootingRpc()
	//{
	//	SetAnim( "RPG_2H_Pose_Standing_Idle_01" );
	//}

	[Rpc.Broadcast]
	public void StartDiggingRpc()
	{
		Manager.Instance.PlaySfxNearby( "zombie.dirt", Position2D, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.7f, maxDist: 320f );

		GameObject.Clone( "prefabs/effects/enemy_spawn_clouds.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( WorldPosition.WithZ( 15f ) ) } );

		SetAnim( "Dig" );

		SetPlaybackRate( 1f );
		CanAnimate = false;
	}

	[Rpc.Broadcast]
	protected void FinishDiggingRpc( Vector2 pos )
	{
		Manager.Instance.PlaySfxNearby( "zombie.dirt", pos, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.7f, maxDist: 320f );

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

		IsSpawning = true;
		TimeSinceSpawn = 0f;
		SpawnProgress = 0f;
		ShouldSpawnInstantly = false;

		PlaySpawnAnim();
		CanAnimate = true;
		SetPlaybackRate( 1f );
	}

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

		PlayFlinchAnim();

		SetState( SpikerState.Default );
	}

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

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

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

	protected override void PlayFlinchAnim()
	{
		
	}
}