things/enemyProjectiles/SpitterProjectile.cs

Projectile entity for an enemy spitter. It manages projectile type, lifespan, movement including Z easing and wind, particle effects, spawning acid puddles for acid projectiles, hit behavior and removal effects.

NetworkingFile Access
using Sandbox;
using System;

public class SpitterProjectile : EnemyProjectile
{
	//[Property] public GameObject Particles { get; set; }
	[Property] public GameObject Model { get; set; }
	[Property] public GameObject ModelAnchor { get; set; }
	[Property] public SkinnedModelRenderer Renderer { get; set; }
	[Sync] public float Damage { get; set; }
	public float Lifetime { get; set; }

	[Sync] public Enemy Shooter { get; set; }
	public EnemyType EnemyType { get; set; }

	public float BaseZPos { get; set; }

	public float HitForce { get; set; }
	protected Color _impactParticleColor;

	[Property] public EnemyProjectileType ProjectileType { get; protected set; }

	protected GameObject _particleObject;

	public virtual float RequiredPlayerCollisionPercent => 0.05f;
	protected float _groundHeight;
	protected EasingType _zPosEasingType;

	private TimeSince _timeSinceAcidPuddle;
	private float _timeUntilNextAcidPuddle;
	private const float POSITION_HISTORY_DELAY = 0.5f;
	private Queue<(float time, Vector2 position)> _positionHistory = new();
	public Vector2 DelayedPosition { get; private set; }
	private bool _hasSetDelayedPosition = false;

	public virtual bool ShouldSpin => true;
	private float _spinSpeed;

	protected float _velocityScale;

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

		//Model.LocalRotation = Rotation.Random;

		_spinSpeed = Game.Random.Float( 250f, 700f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);

		CreateParticles();

		if ( IsProxy )
			return;

		_velocityScale = Game.Random.Float( 0.95f, 1.05f );
			 
		_groundHeight = 0f;
		_zPosEasingType = EasingType.QuadIn;

		ShouldCheckBounds = Manager.Instance.EnemyProjectileBounceFenceLevel > 0;
	}

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

		switch ( projectileType )
		{
			case EnemyProjectileType.Normal:
			default:
				HitForce = 220f;
				Damage = Utils.Select( Manager.Instance.Difficulty, 7f, 8f, 9f );
				Lifetime = 6f;
				break;
			case EnemyProjectileType.Acid:
				HitForce = -50f;
				Damage = Utils.Select( Manager.Instance.Difficulty, 4f, 5f, 6f );
				Lifetime = 4f;
				_timeUntilNextAcidPuddle = Game.Random.Float( 0.1f, 0.2f ) * Utils.Select( Manager.Instance.Difficulty, 1.25f, 1f, 1f );
				_timeSinceAcidPuddle = 0f;
				break;
			case EnemyProjectileType.Fire:
				HitForce = 50f;
				Damage = Utils.Select( Manager.Instance.Difficulty, 2f, 3f, 4f );
				Lifetime = 6.5f;
				break;
			case EnemyProjectileType.Freeze:
				HitForce = 60f;
				Damage = Utils.Select( Manager.Instance.Difficulty, 4f, 5f, 6f );
				Lifetime = 8f;
				break;
			case EnemyProjectileType.Poison:
				HitForce = 50f;
				Damage = Utils.Select( Manager.Instance.Difficulty, 3f, 4f, 5f );
				Lifetime = 4.5f;
				break;
			case EnemyProjectileType.Curse:
				HitForce = 30f;
				Damage = Utils.Select( Manager.Instance.Difficulty, 3f, 3f, 3f );
				Lifetime = 5f;
				break;
		}

		Lifetime *= Utils.Select( Manager.Instance.Difficulty, 0.85f, 1f, 1.2f );
	}

	protected virtual void CreateParticles()
	{
		switch ( ProjectileType )
		{
			case EnemyProjectileType.Normal:
			default:
				_particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_normal.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
				_particleObject.LocalPosition = new Vector3( 0f, 0f, 0f );
				_impactParticleColor = new Color(0.7f, 0.7f, 0.7f);
				break;
			case EnemyProjectileType.Acid:
				_particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_acid.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
				_particleObject.LocalPosition = new Vector3( 0f, 0f, 0f );
				_impactParticleColor = Color.Yellow;
				break;
			case EnemyProjectileType.Fire:
				_particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_fire.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
				_particleObject.LocalPosition = new Vector3( 0f, 0f, 15f );
				_impactParticleColor = new Color( 1f, 0f, 0.8f );
				break;
			case EnemyProjectileType.Freeze:
				_particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_freeze.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
				_particleObject.LocalPosition = new Vector3( 0f, 0f, 0f );
				_impactParticleColor = new Color( 0.3f, 0.5f, 1f );
				break;
			case EnemyProjectileType.Poison:
				_particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_poison.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
				_particleObject.LocalPosition = new Vector3( 0f, 0f, 0f );
				_impactParticleColor = Color.Green;
				break;
			case EnemyProjectileType.Curse:
				_particleObject = GameObject.Clone( "prefabs/enemyProjectiles/enemy_projectile_particles_curse.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
				_particleObject.LocalPosition = new Vector3( 0f, 0f, 0f );
				_impactParticleColor = new Color(0.2f, 0f, 0.7f);
				break;
		}
	}

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

		if( ShouldSpin )
			ModelAnchor.LocalRotation = new Angles( 0f, 0f, ModelAnchor.LocalRotation.Roll() + _spinSpeed * Time.Delta );

		if ( IsProxy )
			return;

		if( ProjectileType == EnemyProjectileType.Acid )
		{
			UpdatePositionHistory();

			if ( TimeSinceSpawn > 0.4f && TimeSinceSpawn < Lifetime - 0.3f && _timeSinceAcidPuddle > _timeUntilNextAcidPuddle && _hasSetDelayedPosition )
			{
				_timeSinceAcidPuddle = 0f;
				_timeUntilNextAcidPuddle = Game.Random.Float( 0.2f, 0.55f ) * Utils.Select( Manager.Instance.Difficulty, 1.5f, 1.2f, 0.9f );

				var acidDmg = Utils.Select( Manager.Instance.Difficulty, 4f, 6f, 9f );
				Manager.Instance.SpawnAcidPuddle( DelayedPosition, lifetime: Game.Random.Float( 4f, 5f ), acidDmg, scale: Game.Random.Float( 0.8f, 0.9f ), Color.Yellow, new Color( 0.5f, 0.5f, 0f ), playerSource: null, enemySource: Shooter, enemyType: EnemyType );
			}
		}

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

		if ( Manager.Instance.IsWindActive )
			Velocity += Manager.Instance.GlobalWindForce * Time.Delta;

		var zPos = Utils.Map( TimeSinceSpawn, 0f, Lifetime, BaseZPos, 0f, _zPosEasingType );
		WorldPosition = (WorldPosition + (Vector3)Velocity * _velocityScale * Time.Delta).WithZ( zPos );

		if ( TimeSinceSpawn > Lifetime )
		{
			RemoveRpc( shouldSpawnEffects: Manager.Instance.IsInBounds( Position2D ) );
		}

		HandleRotation();
	}

	protected virtual void HandleRotation()
	{
		//WorldRotation = WorldRotation.RotateAroundAxis( Vector3.Forward, 500f * Time.Delta );
	}

	private void UpdatePositionHistory()
	{
		float currentTime = Time.Now;

		_positionHistory.Enqueue( (currentTime, Position2D) );

		float targetTime = currentTime - POSITION_HISTORY_DELAY;
		while ( _positionHistory.Count > 1 && _positionHistory.Peek().time < targetTime )
		{
			DelayedPosition = _positionHistory.Dequeue().position;
			_hasSetDelayedPosition = true;
		}
	}

	[Rpc.Broadcast]
	public void PlayerHitRpc( bool shouldSpawnEffects, bool multiHit, Vector2 playerPos )
	{
		if ( IsProxy )
			return;

		if ( multiHit )
			Velocity = (Position2D - playerPos).Normal * Math.Max( Velocity.Length, 200f );
		else
			RemoveRpc( shouldSpawnEffects );
	}

	protected override void Remove( bool shouldSpawnEffects )
	{
		base.Remove( shouldSpawnEffects );

		Manager.Instance.SpawnBulletImpactParticles( WorldPosition.WithZ( Math.Max( WorldPosition.z, 10f ) ), Vector3.Up, _impactParticleColor );

		// todo: should this be done on client or just owner?
		if( _particleObject.IsValid() )
		{
			_particleObject.SetParent( null );
			var emitter = _particleObject.GetComponent<ParticleSphereEmitter>();
			emitter.DestroyOnEnd = true;
			emitter.Loop = false;
		}

		if ( shouldSpawnEffects )
		{
			switch ( ProjectileType )
			{
				case EnemyProjectileType.Normal:
				default:
					break;
				case EnemyProjectileType.Acid:
					// todo: different sfx, not the same as player getting hurt by acid
					Manager.Instance.PlaySfxNearbyRpc( "puddle_splat", Position2D, pitch: Game.Random.Float( 1.05f, 1.1f ), volume: 1.2f, maxDist: 350f );
					break;
				case EnemyProjectileType.Fire:
					break;
				case EnemyProjectileType.Freeze:
					break;
			}
		}

		if ( IsProxy )
			return;

		if ( shouldSpawnEffects )
		{
			switch ( ProjectileType )
			{
				case EnemyProjectileType.Normal:
				default:
					break;
				case EnemyProjectileType.Acid:
					// todo: don't spawn acid puddle on top of existing acid puddles
					var acidDmg = Utils.Select( Manager.Instance.Difficulty, 4f, 6f, 9f );
					Manager.Instance.SpawnAcidPuddle( Position2D, lifetime: Game.Random.Float( 8f, 10f ), acidDmg, scale: Game.Random.Float( 1.1f, 1.3f ), Color.Yellow, new Color( 0.5f, 0.5f, 0f ), playerSource: null, enemySource: Shooter, enemyType: EnemyType );
					break;
				case EnemyProjectileType.Fire:
					var fireDmg = Utils.Select( Manager.Instance.Difficulty, 3f, 8f, 10f );
					Manager.Instance.SpawnFireGroundRpc( Position2D, player: null, enemySource: Shooter, enemyType: EnemyType, fireDmg, lifetime: Game.Random.Float( 10f, 12f ), spreadChance: 0f, canStack: false, scale: 1f, colorA: Color.Magenta, colorB: Color.Red, hurtPlayers: true, hurtEnemies: false );
					break;
				case EnemyProjectileType.Freeze:
					break;
			}
		}
	}

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

		SetDirection( Velocity.Normal );
		TimeSinceSpawn = 0f;
		BaseZPos = MathX.Lerp( WorldPosition.z, BaseZPos, 0.5f );

		// todo: sfx
	}
}