things/enemies/Spiker.cs

An enemy NPC class "Spiker" derived from Enemy. It controls movement, shooting spikes, digging (teleporting), collisions with players and other enemies, animations, sound effects, and various timers and state transitions.

NetworkingFile Access
using Sandbox;
using static Sandbox.VertexLayout;

public class Spiker : Enemy
{
	private TimeSince _damageTime;
	private const float DAMAGE_TIME = 0.75f;

	private float _shootDelayTimer;
	private const float SHOOT_DELAY_MIN = 2f;
	private const float SHOOT_DELAY_MAX = 4f;

	public bool IsShooting { get; private set; }
	private float _shotTimer;
	private const float SHOOT_TIME = 4f;
	private bool _hasShot;
	private TimeSince _prepareStartTime;
	private bool _hasReversed;

	public override float HeightVariance => 0.02f;
	public override float WidthVariance => 0.01f;

	private bool _moveClockwise;
	public static int SpikerNum { get; set; }
	private float _perpendicularMaxDist;

	private float _digDelayTimer;
	private const float DIG_DELAY_MIN = 4f;
	private const float DIG_DELAY_MAX = 13f;
	public bool IsDigging { get; private set; }
	private TimeSince _timeSinceStartDigging;
	private const float DIG_TIME = 1.2f;

	public float ShootRange { get; set; }

	public float MoveSpeed { get; set; }

	protected override void OnAwake()
	{
		//OffsetY = -0.58f;
		ShadowScale = 1.2f;
		ShadowFullOpacity = 0.8f;
		ShadowOpacity = 0f;

		Scale = 1.4f;

		base.OnAwake();

		//AnimSpeed = 4f;
		//Sprite.Texture = Texture.Load("textures/sprites/spiker.vtex");

		PushStrength = 8f;
		Deceleration = 2.57f;
		DecelerationAttacking = 2.35f;
		AggroRange = 0.75f;

		Radius = 0.27f;

		Health = 80f;

		if ( Manager.Instance.Difficulty < 0 )
			Health = 70f;

		MaxHealth = Health;
		DamageToPlayer = 14f;

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

		Sprite.PlayAnimation( AnimSpawnPath );

		if ( IsProxy )
			return;
		
		CollideWith.Add( typeof( Enemy ) );
		CollideWith.Add( typeof( Player ) );

		_damageTime = DAMAGE_TIME;
		_shootDelayTimer = Game.Random.Float( SHOOT_DELAY_MIN, SHOOT_DELAY_MAX );
		_digDelayTimer = Game.Random.Float( DIG_DELAY_MIN, DIG_DELAY_MAX );

		_moveClockwise = SpikerNum % 2 == 0;
		SpikerNum++;
		_perpendicularMaxDist = Game.Random.Float( 3.5f, 6.5f );

		ShootRange = 4.5f;

		MoveSpeed = 0.9f;
	}

	protected override void UpdatePosition( float dt )
	{
		base.UpdatePosition( dt );

		//if(!IsDigging)
		//{
		//	Gizmo.Draw.Color = Color.White;
		//	Gizmo.Draw.Text( $"{_digDelayTimer}", new global::Transform( (Vector3)Position2D + new Vector3( 0f, -0.4f, 0f ) ) );
		//}

		var targetPos = GetTargetPos();

		if ( IsShooting )
		{
			Velocity *= (1f - dt * (IsAttacking ? DecelerationAttacking : Deceleration));
			if ( !_hasShot && _prepareStartTime > 1f )
			{
				CreateSpike();
				_hasShot = true;
			}

			if ( !_hasReversed && _prepareStartTime > 3f )
			{
				_hasReversed = true;
				Sprite.PlayAnimation( "shoot_reverse" );
			}

			Velocity *= (1f - dt * 4f);

			_shotTimer -= dt;
			if ( _shotTimer < 0f )
			{
				FinishShooting();
				return;
			}
		}
		else if ( IsDigging )
		{
			Velocity *= (1f - dt * 6f);

			if ( _timeSinceStartDigging > DIG_TIME )
			{
				Vector2 pos;
				if ( Target.IsValid() )
				{
					pos = targetPos + Target.Velocity * Game.Random.Float( 0f, 2f ) + Utils.GetRandomVector() * Game.Random.Float( 1.5f, 5.5f);
					if ( (Position2D - pos).LengthSquared < MathF.Pow( 0.5f, 2f ) )
						pos = Position2D + Utils.GetRandomVector() * Game.Random.Float( 3f, 5f );
				}
				else
				{
					pos = Position2D + Game.Random.Float( 4f, 5f );
				}

				FinishDigging( Manager.Instance.ClampToBounds( pos ) );
			}
			else
			{
				float progress = Utils.Map( _timeSinceStartDigging, 0f, DIG_TIME, 0f, 1f );
				//ZPos = Utils.Map( progress, 0f, 1f, 0f, DIG_DEPTH );
				//PlaybackRate = Utils.Map( progress, 0f, 1f, 0f, 1f ) * _personalSpeedScale;

				float shadowOpacity = Utils.Map( progress, 0f, 1f, ShadowOpacity, 0f, EasingType.QuadIn );
				ShadowSprite.Tint = Color.Black.WithAlpha( shadowOpacity );

				VfxOpacity = Utils.Map( progress, 0f, 1f, 1f, 0f, EasingType.QuadIn );

				//Gizmo.Draw.Color = Color.White;
				//Gizmo.Draw.Text( $"{progress}", new global::Transform( (Vector3)Position2D + new Vector3( 0f, -0.4f, 0f ) ) );

				IgnoreCollision = progress > 0.3f;

				if ( _spawnCloudTime > (0.3f / TimeScale) )
				{
					var cloud = Manager.Instance.SpawnCloud( Position2D + new Vector2( 0f, 0.05f ) );
					cloud.Velocity = new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ).Normal * Game.Random.Float( 0.2f, 0.6f );
					_spawnCloudTime = Game.Random.Float( 0f, 0.15f );
				}
			}

			return;
		}
		else
		{
			Vector2 toTarget = (targetPos - Position2D).Normal;

			if(Manager.Instance.Difficulty >= 1)
			{
				if ( (targetPos - Position2D).LengthSquared < MathF.Pow( _perpendicularMaxDist, 2f ) )
					toTarget = Vector2.Lerp( toTarget, new Vector2( toTarget.y, -toTarget.x ) * (_moveClockwise ? -1f : 1f), Utils.Map( (targetPos - Position2D).LengthSquared, MathF.Pow( _perpendicularMaxDist, 2f ), MathF.Pow( 1.5f, 2f ), 0f, 1f ) );
			}

			Velocity += toTarget * 1.0f * dt * (IsFeared ? -1f : 1f);
		}

		float speed = MoveSpeed * (IsAttacking ? 1.3f : 0.7f) + Utils.FastSin( MoveTimeOffset + Time.Now * (IsAttacking ? 15f : 7.5f) ) * (IsAttacking ? 0.66f : 0.35f);

		if ( Manager.Instance.Difficulty < 0 )
			speed *= 0.85f;

		WorldPosition += (Vector3)Velocity * speed * dt;

		if ( !IsShooting && !IsDigging && (!IsAttacking || IsCharmed) && !Manager.Instance.IsGameOver )
		{
			var target_dist_sqr = ((Target.IsValid() ? Target.Position2D : targetPos) - Position2D).LengthSquared;
			var rangeSqr = ShootRange * ShootRange;

			if ( Manager.Instance.Difficulty < 0 )
				rangeSqr *= 0.7f;

			if ( target_dist_sqr < rangeSqr )
			{
				_shootDelayTimer -= dt;
				if ( _shootDelayTimer < 0f )
					StartShooting();
			}

			if ( target_dist_sqr < MathF.Pow( 12f, 2f ) && Manager.Instance.Difficulty > 0 )
			{
				_digDelayTimer -= dt;
				if ( _digDelayTimer < 0f )
					StartDigging();
			}
		}
	}

	protected virtual Vector2 GetTargetPos()
	{
		return Target.IsValid() ? Target.Position2D : (IsCharmed ? Manager.Instance.Player.Position2D : Position2D);
	}

	protected override void UpdateSprite( Thing target )
	{
		if ( Sprite.CurrentAnimation.Name.Contains( "shoot" ) || IsDigging ) 
			return;

		base.UpdateSprite( target );
	}

	public void StartShooting()
	{
		_shotTimer = SHOOT_TIME;
		IsShooting = true;
		CanAttack = false;
		CanTurn = false;
		CanAttackAnim = false;
		_hasShot = false;
		_hasReversed = false;
		_prepareStartTime = 0f;
		Velocity *= 0.25f;
		DontChangeAnimSpeed = true;
		AnimSpeed = 1f;
		Sprite.PlayAnimation( "shoot" );

		ShouldUpdateAfterGameOver = true;
	}

	public virtual void CreateSpike()
	{
		var target_pos = Target.IsValid()
			? Target.Position2D + Target.Velocity * Game.Random.Float( 0.2f, 2f ) + new Vector2( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * 0.8f
			: Position2D + Utils.GetRandomVector() * Game.Random.Float( 3f, 6f );

		var BUFFER = 0.1f;

		var spike = Manager.Instance.SpawnEnemySpike( new Vector2( Math.Clamp( target_pos.x, Manager.Instance.BOUNDS_MIN.x + BUFFER, Manager.Instance.BOUNDS_MAX.x - BUFFER ), Math.Clamp( target_pos.y, Manager.Instance.BOUNDS_MIN.y + BUFFER, Manager.Instance.BOUNDS_MAX.y - BUFFER ) ) );

		if(IsCharmed)
		{
			spike.BecomeCharmed();
		}

		Manager.Instance.PlaySfxNearby( "spike.prepare", target_pos, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1.5f, maxDist: 5f );
	}

	public void FinishShooting()
	{
		_shootDelayTimer = Game.Random.Float( SHOOT_DELAY_MIN, SHOOT_DELAY_MAX ) * (Manager.Instance.Difficulty < 0 ? 2.5f : 1f);
		IsShooting = false;
		CanAttack = true;
		CanAttackAnim = true;
		CanTurn = true;
		DontChangeAnimSpeed = false;
		Sprite.PlayAnimation( AnimIdlePath );

		ShouldUpdateAfterGameOver = false;
		if ( Manager.Instance.IsGameOver && !Manager.Instance.ShouldUpdateThings )
			Celebrate();
	}

	public override void Colliding( Thing other, float percent, float dt )
	{
		base.Colliding( other, percent, dt );

		if ( other is Enemy enemy && !enemy.IsDying )
		{
			var spawnFactor = Utils.Map( enemy.TimeSinceSpawn, 0f, enemy.SpawnTime, 0f, 1f, EasingType.QuadIn );
			Velocity += (Position2D - enemy.Position2D).Normal * Utils.Map( percent, 0f, 1f, 0f, 1f ) * enemy.PushStrength * (1f + enemy.TempWeight) * spawnFactor * dt;

			if ( IsAttacking && IsCharmed != enemy.IsCharmed && _damageTime > (DAMAGE_TIME / TimeScale) )
			{
				var dmg = DamageToPlayer;
				if ( IsCharmed )
					dmg *= CharmDamageDealtMultiplier;

				enemy.Damage( dmg, null, addVel: Vector2.Zero, addTempWeight: 0f, isCrit: false, DamageType.Melee );
				enemy.Target = this;
				_damageTime = 0f;
				Manager.Instance.PlaySfxNearby( "zombie.attack.player", Position2D, pitch: Utils.Map( enemy.Health, enemy.MaxHealth, 0f, 0.95f, 1.15f, EasingType.QuadIn ), volume: 0.6f, maxDist: 4.5f );
			}
		}
		// todo: move collision check to player instead to prevent laggy hits?
		else if ( other is Player player )
		{
			if ( !player.IsDead )
			{
				Velocity += (Position2D - player.Position2D).Normal * Utils.Map( percent, 0f, 1f, 0f, 1f ) * player.Stats[PlayerStat.PushStrength] * (1f + player.TempWeight) * dt;

				if ( IsAttacking && _damageTime > (DAMAGE_TIME / TimeScale) && !IsCharmed )
				{
					float dmg = player.CheckDamageAmount( DamageToPlayer, DamageType.Melee );

					if ( !player.IsInvulnerable && !player.IsTimePausedForChoosing )
					{
						Manager.Instance.PlaySfxNearby( "zombie.attack.player", Position2D, pitch: Utils.Map( player.Health, player.Stats[PlayerStat.MaxHp], 0f, 0.95f, 1.15f, EasingType.QuadIn ), volume: 1f, maxDist: 5.5f );

						player.Damage( dmg );

						if( dmg > 0f )
							OnDamagePlayer( player, dmg );
					}

					_damageTime = 0f;
				}
			}
		}
	}

	public override void Damage( float damage, Player player, Vector2 addVel, float addTempWeight, bool isCrit = false, DamageType damageType = DamageType.PlayerBullet )
	{
		base.Damage( damage, player, addVel, addTempWeight, isCrit, damageType );

		if ( Game.Random.Float( 0f, 1f ) < Utils.Map( damage, 1f, 20f, 0.1f, 0.7f ) )
			_digDelayTimer *= Game.Random.Float( 0.6f, 0.95f );
	}

	public void StartDigging()
	{
		IsDigging = true;
		_timeSinceStartDigging = 0f;
		CanAttack = false;
		CanAttackAnim = false;
		CanTurn = false;
		Velocity *= 0.5f;
		Sprite.PlayAnimation( "dig" );
		Manager.Instance.PlaySfxNearby( "zombie.dirt", Position2D, pitch: Game.Random.Float( 0.5f, 0.55f ), volume: 0.6f, maxDist: 7.5f );
		//SS2Game.PlaySfx( "zombie.dirt", Position, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.7f );
		//SS2Game.Current.DustCloudClient( Position2D );

		ShouldUpdateAfterGameOver = true;
	}

	void FinishDigging( Vector2 pos )
	{
		Position2D = pos;
		//WorldPosition = ((Vector3)Position2D).WithZ( ZPos );

		Transform.ClearInterpolation();

		IsDigging = false;
		CanAttack = true;
		CanAttackAnim = true;
		CanTurn = true;
		IgnoreCollision = false;
		//SetAnim( "Attack1" );

		_moveClockwise = !_moveClockwise;

		_digDelayTimer = Game.Random.Float( DIG_DELAY_MIN, DIG_DELAY_MAX );

		//SS2Game.PlaySfx( "zombie.dirt", Position, pitch: Game.Random.Float( 0.6f, 0.8f ), volume: 0.7f );
		//SS2Game.Current.DustCloudClient( Position2D );

		IsSpawning = true;
		//_hasDug = true;
		TimeSinceSpawn = 0f;
		//SpawnProgress = 0f;

		//ShadowRadiusModifier = 1.5f;
		//ShadowOpacityModifier = 0f;

		Manager.Instance.PlaySfxNearby( "zombie.dirt", Position2D, pitch: Game.Random.Float( 0.85f, 0.9f ), volume: 0.6f, maxDist: 7.5f );

		ShadowSprite.Tint = Color.Black.WithAlpha( 0f );
		VfxOpacity = 0f;
	}

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

		if ( IsShooting || IsDigging )
			return;

		CelebrateAsync();
	}

	async void CelebrateAsync()
	{
		await Task.Delay( Game.Random.Int( 0, 500 ) );

		Sprite.PlaybackSpeed = Game.Random.Float( 1.5f, 3.5f );

		Sprite.PlayAnimation( "cheer_start" );

		await Task.Delay( Game.Random.Int( 200, 400 ) );

		Sprite.PlayAnimation( "cheer" );
	}

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

		ShouldUpdateAfterGameOver = false;
		if ( Manager.Instance.IsGameOver && !Manager.Instance.ShouldUpdateThings )
			Celebrate();
	}
}