things/Bullet.cs

Bullet component for a projectile. Stores stats, handles movement (steering, homing, random movement, returning), collisions with enemies/players/obstacles, piercing/bouncing/splash/explosion logic, visual state (model, tint, size) and RPCs for removal and lens buff.

NetworkingFile Access
using System;
using Sandbox;

public enum BulletType { Normal, Armor, FrozenShard, Punch }
public enum BulletStat
{
	Damage, Force, AddTempWeight, Lifetime, NumPiercing, NumBouncing, CriticalChance, CriticalMultiplier, 
	ApplyFire, ApplyFreeze, ApplyPoison, 
	BulletSpread, BulletInaccuracy, BulletLifetime,
	GrowDamageAmount, DistanceDamageAmount, HealTeammateAmount, MoveRandomly, HomingRadius, IsReturning, SplashDamagePercent, BounceDamageIncrease, BounceResetLifetime, 
	CanHitShooter, FriendlyFire, BounceTarget, ArcHeight, OverflowPercent, IsArmorBullet, StartFromGround, NumGroundHops,
	NumPunchExtraHits, IsForcePunch, AimAtCursorProgress, ForceRandomDir, Explosive, LifestealPercent,
}

public class Bullet : Thing
{
	[Property] public ModelRenderer Model { get; set; }

	public Dictionary<BulletStat, float> Stats = new();

	public Player Shooter { get; set; }

	public TimeSince TimeSinceInitialSpawn { get; protected set; } // not restarted when bouncing etc
	private bool _hasFinishedFadingIn;
	public const float FADE_IN_TIME = 0.1f;

	private TimeSince _timeSinceBounce = 99f;

	public float BaseZPos { get; set; }

	public List<Thing> HitThings { get; private set; } = new();

	public BulletHomingDetector HomingDetector { get; set; }
	public bool HasHomed { get; set; }

	private bool _hasReturned;
	private TimeSince _timeSinceSteer;

	private bool _hasDoneFirstUpdate;

	public int StartingNumPierce { get; set; }
	public int StartingNumBounce { get; set; }

	private bool _sizeDirty;

	private TimeSince _timeSinceUpdateDamage;
	private const float UPDATE_DAMAGE_INTERVAL = 0.2f;

	private bool _shouldMoveRandomly;
	private TimeSince _timeSinceRandomMove;
	private float _randomMoveDelay;

	private const float SPLASH_RADIUS = 65f;

	[Property, Hide] public bool ShowPierce { get; set; }
	[Property, Hide] public bool ShowSplash { get; set; }
	[Property, Hide] public bool ShowBounce { get; set; }
	[Property, Hide] public BulletType BulletType { get; set; }
	[Property, Hide] public Color Color { get; set; } = Color.White;

	public bool IsLensBuffed { get; set; }

	private bool _isRemoved;

	private bool _shouldAimAtCursor;

	public Vector2 LastPos2D { get; private set; }

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

		Model.Tint = Color.White.WithAlpha( 0f );

		if ( IsProxy )
			return;
	}

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

		Radius = 5f;

		Transform.ClearInterpolation();

		TimeSinceInitialSpawn = 0f;

		if ( IsProxy )
			return;

		CollideWithTags.Add( "enemy" );
		CollideWithTags.Add( "orbiter_shield_enemy" );
		CollideWithTags.Add( "obstacle" );

		_timeSinceUpdateDamage = UPDATE_DAMAGE_INTERVAL * 2f;
		_sizeDirty = true;
		_timeSinceRandomMove = 0f;
		_randomMoveDelay = Game.Random.Float( 0.025f, 0.15f );

		LastPos2D = Position2D;
	}

	public void Init()
	{
		DetermineSize();

		StartingNumPierce = (int)Stats[BulletStat.NumPiercing];
		StartingNumBounce = (int)Stats[BulletStat.NumBouncing];
		ShouldCheckBounds = Stats[BulletStat.NumBouncing] > 0f;

		_shouldMoveRandomly = Stats[BulletStat.MoveRandomly] > 0f;
		_shouldAimAtCursor = Stats[BulletStat.AimAtCursorProgress] > 0f;

		ShowPierce = Stats[BulletStat.NumPiercing] > 0f;
		ShowSplash = Stats[BulletStat.SplashDamagePercent] > 0f || Stats[BulletStat.Explosive] > 0f;
		ShowBounce = Stats[BulletStat.NumBouncing] > 0f;

		Vector3 colorVec = new Vector3( 1f, 1f, 1f );
		int numColors = 1;

		//if ( Stats[BulletStat.HomingRadius] > 0f )
		//colorVec += new Vector3( 0.6f, 0.6f, 0f );

		if ( Stats[BulletStat.ApplyFire] > 0f )
		{
			colorVec += new Vector3( 10f, 0f, 0f );
			numColors++;
		}

		if ( Stats[BulletStat.ApplyFreeze] > 0f )
		{
			colorVec += new Vector3( 2f, 2f, 9f );
			numColors++;
		}

		if ( Stats[BulletStat.ApplyPoison] > 0f )
		{
			colorVec += new Vector3( 0f, 10f, 0f );
			numColors++;
		}

		if ( Stats[BulletStat.HealTeammateAmount] > 0f )
		{
			colorVec += new Vector3( 0f, 10f, 0f );
			numColors++;
		}

		if ( numColors > 1 )
		{
			colorVec = (colorVec / numColors).Normal;
			Color = new Color( colorVec.x, colorVec.y, colorVec.z );
		}

		if ( BulletType == BulletType.Punch )
		{
			//Color = Color.WithAlpha( 0.5f );
			Model.Enabled = false;
		}
	}

	void FirstUpdate()
	{
		if ( BulletType == BulletType.Normal )
			RefreshBodyGroups();

		//if ( BulletType != BulletType.Punch )
		//	Model.Tint = Color;

		if ( IsProxy )
			return;

		if ( Stats[BulletStat.HomingRadius] > 0f )
		{
			var detectorGo = GameObject.Clone( "prefabs/bullet_homing_detector.prefab", new CloneConfig { Parent = GameObject, StartEnabled = true } );
			HomingDetector = detectorGo.GetComponent<BulletHomingDetector>();
			HomingDetector.Radius = Stats[BulletStat.HomingRadius];
			HomingDetector.SphereCollider.Radius = Stats[BulletStat.HomingRadius];
			HomingDetector.Bullet = this;
		}

		if ( Stats[BulletStat.CanHitShooter] > 0f || Stats[BulletStat.FriendlyFire] > 0f || Stats[BulletStat.HealTeammateAmount] > 0f )
			CollideWithTags.Add( "player" );

		if ( BulletType == BulletType.Armor )
		{
			Model.Model = Manager.Instance.ArmorBulletModel;
			SetDirection( Velocity.Normal );
			_sizeDirty = true;

			// todo: needs to change color or appearance for pierce, fire, etc?
		}
		else if ( BulletType == BulletType.FrozenShard )
		{
			Model.Model = Manager.Instance.FrozenShardBulletModel;
			_sizeDirty = true;

			// todo: needs to change color or appearance for pierce, fire, etc?
		}

		_hasDoneFirstUpdate = true;
	}

	void RefreshBodyGroups()
	{
		if ( ShowPierce )
			Model.SetBodyGroup( 2, 1 );
		else
			Model.SetBodyGroup( 2, 0 );

		if ( ShowSplash )
			Model.SetBodyGroup( 1, 1 );
		else 
			Model.SetBodyGroup( 1, 0 );

		if ( ShowBounce )
			Model.SetBodyGroup( 0, 1 );
		else
			Model.SetBodyGroup( 0, 0 );
	}

	void DetermineSize()
	{
		var damage = Stats[BulletStat.Damage];
		var scale = damage < 30f
			? Utils.Map( damage, 0f, 30f, 0.4f, 2.25f, EasingType.QuadOut )
			: Utils.Map( damage, 30f, 150f, 2.25f, 3.5f, EasingType.QuadIn );

		Radius = 5f * scale;

		float scaleModifier = 1f;
		if ( BulletType == BulletType.Armor ) // todo: armor sphereCollider is small, since the model is too large and is scaled down
			scaleModifier = Utils.Map( damage, 0f, 5f, 0.2f, 0.13f );
		else if ( BulletType == BulletType.FrozenShard )
			scaleModifier = 0.92f;
		//else if ( BulletType == BulletType.Punch )
		//	scaleModifier = 3f;

		WorldScale = new Vector3( scale * scaleModifier );
		
		//_pfakeshadow.Set("Size", 9f * Scale);

		_sizeDirty = false;
		_timeSinceUpdateDamage = 0f;
	}

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

		if ( !_hasDoneFirstUpdate )
			FirstUpdate();

		if( BulletType != BulletType.Punch )
		{
			if ( !_hasFinishedFadingIn )
			{
				if ( TimeSinceInitialSpawn < FADE_IN_TIME )
				{
					Model.Tint = Color.WithAlpha( Utils.Map( TimeSinceInitialSpawn, 0f, FADE_IN_TIME, 0f, 1f ) );
				}
				else
				{
					Model.Tint = Color;
					_hasFinishedFadingIn = true;
				}
			}
		}

		if ( IsProxy )
			return;

		//var sphereCollider = Collider as SphereCollider;
		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"{sphereCollider.Radius}", new global::Transform( WorldPosition ) );

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"{Stats[BulletStat.NumBouncing]}/{StartingNumBounce}", new global::Transform( WorldPosition + new Vector3( 0f, 0f, -5f ) ) );

		//Gizmo.Draw.LineCircle( WorldPosition, Vector3.Up, Radius );

		if ( Stats[BulletStat.DistanceDamageAmount] > 0f )
		{
			var dist = (Position2D - LastPos2D).Length;
			Stats[BulletStat.Damage] += Stats[BulletStat.DistanceDamageAmount] * Utils.Unit2Meter * dist;
			_sizeDirty = true;
		}

		LastPos2D = Position2D;

		if ( Math.Abs( Stats[BulletStat.GrowDamageAmount] ) > 0f )
		{
			Stats[BulletStat.Damage] += Stats[BulletStat.GrowDamageAmount] * Time.Delta;
			_sizeDirty = true;

			if ( Stats[BulletStat.Damage] <= 0f )
			{
				Remove();
				return;
			}
		}

		if ( _sizeDirty && _timeSinceUpdateDamage > UPDATE_DAMAGE_INTERVAL )
			DetermineSize();

		if( Shooter.IsValid() && Shooter.Stats[PlayerStat.BulletSteering] > 0f )
			HandleSteering();

		if ( _shouldMoveRandomly )
			HandleRandomMovement();

		if ( Stats[BulletStat.IsReturning] > 0f )
			HandleReturning();

		if ( _shouldAimAtCursor )
			HandleAimAtCursor();

		var lifetime = Stats[BulletStat.Lifetime];

		float zPos;
		if( BulletType == BulletType.Punch )
		{
			zPos = BaseZPos;
		}
		else if( Stats[BulletStat.ArcHeight] > 0f )
		{
			var startZPos = Stats[BulletStat.StartFromGround] > 0f ? 0f : BaseZPos;

			zPos = TimeSinceSpawn < lifetime * 0.5f
				? Utils.Map( TimeSinceSpawn, 0f, lifetime * 0.5f, startZPos, Stats[BulletStat.ArcHeight], EasingType.QuadOut )
				: Utils.Map( TimeSinceSpawn, lifetime * 0.5f, lifetime, Stats[BulletStat.ArcHeight], 0f, EasingType.QuadIn );

			WorldRotation = Rotation.From( Utils.Map(TimeSinceSpawn, 0f, lifetime, -65f, 65f ), WorldRotation.Yaw(), 0f );
		}
		else if( Stats[BulletStat.StartFromGround] > 0f )
		{
			zPos = Utils.MapReturn( TimeSinceSpawn, 0f, lifetime, 0f, BaseZPos, EasingType.QuadOut );

			WorldRotation = Rotation.From( Utils.Map( TimeSinceSpawn, 0f, lifetime, -45f, 45f ), WorldRotation.Yaw(), 0f );
		}
		else
		{
			zPos = Utils.Map( TimeSinceSpawn, 0f, lifetime, BaseZPos, 0f, EasingType.QuartIn );

			if ( BulletType != BulletType.Armor )
				WorldRotation = Rotation.From( Utils.Map( TimeSinceSpawn, 0f, lifetime, 0f, 25f, EasingType.QuartIn ), WorldRotation.Yaw(), 0f );
		}

		if ( Manager.Instance.IsWindActive )
			Velocity += Manager.Instance.GlobalWindForce * 2f * Time.Delta; // todo: less affected by wind if larger

		WorldPosition = (WorldPosition + (Vector3)Velocity * Time.Delta).WithZ( zPos );

		if ( TimeSinceSpawn > lifetime )
		{
			HitGround();
			return;
		}
	}

	void HandleSteering()
	{
		if ( _timeSinceSteer < 0.075f )
			return;

		if( Stats[BulletStat.IsReturning] > 0f )
		{
			var lifetimeProgress = Utils.Map( TimeSinceSpawn, 0f, Stats[BulletStat.Lifetime], 0f, 1f );
			if ( lifetimeProgress > 0.5f && lifetimeProgress < 0.75f )
				return;
		}

		if ( _timeSinceBounce < 0.15f )
			return;

		//if ( _shouldMoveRandomly && _timeSinceRandomMove < 0.05f )
		//	return;

		var targetDir = Shooter.FacingDir;
		//var targetDir = (Manager.Instance.MouseWorldPos - Position2D).Normal;
		SetDirection( Utils.DynamicEaseTo( Velocity.Normal, targetDir, Utils.Map( TimeSinceSpawn, 0f, 0.1f, 0f, (_shouldMoveRandomly || HasHomed) ? 0.1f : 0.5f, EasingType.QuadIn ), _timeSinceSteer ) );
		//SetDirection( targetDir );

		_timeSinceSteer = 0f;
	}

	void HandleRandomMovement()
	{
		if( _timeSinceRandomMove > _randomMoveDelay )
		{
			var newDir = Utils.RotateVector( Velocity, Game.Random.Float( 10f, 45f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f) ).Normal;
			SetDirection( newDir );
			_timeSinceRandomMove = 0f;
			_randomMoveDelay = Game.Random.Float( 0.1f, 0.3f );
		}
	}

	void HandleReturning()
	{
		var lifetimeProgress = Utils.Map( TimeSinceSpawn, 0f, Stats[BulletStat.Lifetime], 0f, 1f );
		if ( !_hasReturned && lifetimeProgress > 0.5f )
		{
			if ( Shooter.IsValid() )
			{
				Vector2 dir = (Shooter.Position2D - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
					? (Shooter.Position2D - Position2D).Normal
					: Utils.GetRandomVector();

				SetDirection( dir );
			}
			else
			{
				SetDirection( -Velocity.Normal );
			}

			_hasReturned = true;
			HitThings.Clear();

			if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
				HomingDetector.Refresh();

			_shouldAimAtCursor = Stats[BulletStat.AimAtCursorProgress] > 0f;
		}
	}

	void HandleAimAtCursor()
	{
		var lifetimeProgress = Utils.Map( TimeSinceSpawn, 0f, Stats[BulletStat.Lifetime], 0f, 1f );
		if ( lifetimeProgress > Stats[BulletStat.AimAtCursorProgress] ) // todo: if you get LazyBullets perk, it feels like it takes too long to activate. activate on whichever comes first, lifetime progress or elapsed time threshold?
		{
			var aimWorldPos = Input.UsingController && Shooter.IsValid() ? Shooter.Position2D + Shooter.AimDir * 1000f : Manager.Instance.MouseWorldPos;
			var targetDir = (aimWorldPos - Position2D).Normal;
			SetDirection( targetDir );

			_shouldAimAtCursor = false;
		}
	}

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

		if ( _isRemoved || !Shooter.IsValid() || HitThings.Contains( other ) )
			return;

		bool didHit = false;
		bool hitObstacle = false;
		float dmg = Stats[BulletStat.Damage];
		float dmgUsed = 0f;

		if ( other is Enemy enemy )
		{
			//if ( enemy.IsDying || (enemy.IsSpawning && enemy.SpawnProgress < 0.7f) )
			if ( enemy.IsSpawning && enemy.SpawnProgress < 0.7f )
				return;

			if ( Stats[BulletStat.ArcHeight] > 0f && TimeSinceSpawn < Stats[BulletStat.Lifetime] * 0.25f )
				return;

			didHit = true;

			bool isCrit = CheckCrit( ref dmg );

			dmgUsed = enemy.IsInvincible ? dmg : Math.Min( enemy.Health, dmg );
			bool overflow = Stats[BulletStat.OverflowPercent] > 0f && dmgUsed < dmg;

			if( !enemy.IsInvincible )
			{
				if ( Stats[BulletStat.ApplyFire] > 0f )
					enemy.Ignite( playerSource: Shooter, enemySource: null, enemyType: EnemyType.None, Shooter.Stats[PlayerStat.FireDamage], Shooter.Stats[PlayerStat.FireLifetime], Shooter.Stats[PlayerStat.FireSpreadChance], Shooter.Stats[PlayerStat.FireDmgStack] > 0f );

				if ( Stats[BulletStat.ApplyFreeze] > 0f )
					enemy.Freeze( playerSource: Shooter, enemySource: null, Shooter.Stats[PlayerStat.FreezeTimeScale], Shooter.Stats[PlayerStat.FreezeLifetime] );

				if ( Stats[BulletStat.ApplyPoison] > 0f && !enemy.IsInanimate )
				{
					enemy.Poison(
						Shooter,
						enemySource: null,
						enemyType: EnemyType.None,
						Shooter.Stats[PlayerStat.PoisonDamage],
						Shooter.Stats[PlayerStat.PoisonFinishDamagePercent],
						Shooter.Stats[PlayerStat.PoisonDieSpreadChance],
						Shooter.Stats[PlayerStat.RadiusMultiplier],
						Shooter.Stats[PlayerStat.PoisonFlammable] > 0f,
						Shooter.Stats[PlayerStat.PoisonTickTimeModifier],
						(int)Shooter.Stats[PlayerStat.PoisonNumHitsToRemove]
					);
				}
			}

			Vector2 dir = Velocity.Normal;

			float damageDealt = overflow ? dmgUsed : dmg;

			// todo: overflow damage doesn't include additional dmg to enemy...

			var shouldFlinch =  damageDealt < enemy.MaxHealth * 0.05f ? false : true;

			var forceFactor = Stats[BulletStat.Damage] < 5f
				? Utils.Map( Stats[BulletStat.Damage], 0f, 5f, 0f, 1f )
				: Utils.Map( Stats[BulletStat.Damage], 5f, 100f, 1f, 4f );

			var forceDir = Stats[BulletStat.ForceRandomDir] > 0f ? (Game.Random.Float( 0f, 1f ) < 0.4f ? Utils.GetRandomVectorInCone( -dir, 180f ) : Utils.GetRandomVector()) : dir;
			var force = forceDir * Stats[BulletStat.Force] * forceFactor;

			if ( Manager.Instance.LaunchEnemies && !enemy.IsInTheAir && !enemy.IsSpawning )
			{
				var targetPos = enemy.Position2D + Utils.GetRandomVector() * Game.Random.Float( 80f, 350f );

				enemy.JumpRpc( Manager.Instance.ClampPosToBounds( targetPos ), height: Game.Random.Float( 80f, 120f ), lifetime: Game.Random.Float( 1.1f, 1.5f ) );
			}

			bool shouldDmgEnemy = true;
			if( Stats[BulletStat.Explosive] > 0f )
			{
				int numPierce = (int)Stats[BulletStat.NumPiercing];
				int numBounce = (int)Stats[BulletStat.NumBouncing];
				if ( numPierce == 0 && numBounce == 0 )
					shouldDmgEnemy = false; // it should only apply explosion damage, not direct hit damage
			}

			if( shouldDmgEnemy )
			{
				var damageType = BulletType == BulletType.Punch ? DamageType.Punch : DamageType.Bullet;
				enemy.DamageRpc( damageDealt, Shooter, damageType, WorldPosition, force, isCrit, shouldFlinch );

				if( Stats[BulletStat.LifestealPercent] > 0f && Shooter.IsValid() && !Shooter.IsDead )
				{
					float healAmount = damageDealt * (Stats[BulletStat.LifestealPercent]);
					Shooter.Heal( healAmount );
				}
			}

			if ( Stats[BulletStat.IsForcePunch] > 0f )
				enemy.Punched( Shooter );
		}
		else if ( other is Player player )
		{
			if ( Stats[BulletStat.HealTeammateAmount] > 0f && player != Shooter && player.Health < player.GetSyncStat(PlayerStat.MaxHp) )
			{
				didHit = true;

				// todo: sfx
				player.HealRpc( Stats[BulletStat.HealTeammateAmount], otherPlayerHealer: Shooter );
			}
			else if ( TimeSinceSpawn > 0.1f && ( ( Stats[BulletStat.CanHitShooter] > 0f && player == Shooter && !player.IsProxy) || (player != Shooter && player.GetSyncStat(PlayerStat.TakeFriendlyDmg) > 0f) ) )
			{
				if ( Stats[BulletStat.ApplyFire] > 0f )
					player.Ignite( Shooter, enemySource: null, enemyType: EnemyType.None, Shooter.Stats[PlayerStat.FireDamage], Shooter.Stats[PlayerStat.FireLifetime], Shooter.Stats[PlayerStat.FireSpreadChance], Shooter.Stats[PlayerStat.FireDmgStack] > 0f );

				if ( Stats[BulletStat.ApplyFreeze] > 0f )
					player.Freeze( playerSource: Shooter, enemySource: null, Shooter.Stats[PlayerStat.FreezeTimeScale], Shooter.Stats[PlayerStat.FreezeLifetime] );

				if ( Stats[BulletStat.ApplyPoison] > 0f )
				{
					player.Poison(
						Shooter,
						enemySource: null,
						enemyType: EnemyType.None,
						Shooter.Stats[PlayerStat.PoisonDamage],
						Shooter.Stats[PlayerStat.PoisonFinishDamagePercent],
						Shooter.Stats[PlayerStat.PoisonDieSpreadChance],
						Shooter.Stats[PlayerStat.RadiusMultiplier],
						Shooter.Stats[PlayerStat.PoisonFlammable] > 0f,
						Shooter.Stats[PlayerStat.PoisonTickTimeModifier],
						(int)Shooter.Stats[PlayerStat.PoisonNumHitsToRemove]
					);
				}

				Vector2 dir = (player.Position2D - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
					? (player.Position2D - Position2D).Normal
					: Utils.GetRandomVector();

				var forceFactor = Stats[BulletStat.Damage] < 5f
				? Utils.Map( Stats[BulletStat.Damage], 0f, 5f, 0f, 1f )
				: Utils.Map( Stats[BulletStat.Damage], 5f, 100f, 1f, 4f );

				//if ( Stats[BulletStat.Force] > 0f )
				//	player.AddVelocity( dir * Stats[BulletStat.Force] * (1f / 2f) ); //(1f / player.Weight);
				var force = Stats[BulletStat.Force] * (1f / player.Weight) * forceFactor;

				didHit = true;
				dmgUsed = Math.Min( player.Health, dmg );
				bool overflow = Stats[BulletStat.OverflowPercent] > 0f && dmgUsed < dmg;

				bool isCrit = Game.Random.Float( 0f, 1f ) < Stats[BulletStat.CriticalChance];
				float damage = (overflow ? dmgUsed : dmg) * (isCrit ? Stats[BulletStat.CriticalMultiplier] : 1f);

				var hitPos = player.Position2D - dir * player.Radius;
				var isSelfInflicted = Stats[BulletStat.CanHitShooter] > 0f && player == Shooter;

				var damageFlags = PlayerDamageFlags.None;
				if ( isSelfInflicted )
					damageFlags |= PlayerDamageFlags.SelfInflicted;

				player.DamageRpc( damage, DamageType.Bullet, hitPos, dir, upwardAmount: 0f, force, ragdollForce: force * 0.1f, enemySource: null, enemyType: EnemyType.None, damageFlags: damageFlags );
			}
		}
		else if ( other is OrbiterShieldEnemy orbiterShieldEnemy )
		{
			if ( orbiterShieldEnemy.IsActive )
			{
				orbiterShieldEnemy.Block( Position2D );

				var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.5f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
				Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition.WithZ( 10f ), Vector3.Up, Color.White, scaleMultiplier );

				Remove();
			}
		}
		else if( other is Obstacle obstacle )
		{
			didHit = true;

			var normal = (Position2D - obstacle.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
				? (Position2D - obstacle.Position2D).Normal
				: Utils.GetRandomVector();

			Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition, normal, Color.White );

			obstacle.PlayHitSfxRpc( WorldPosition );

			hitObstacle = true;
		}

		if ( didHit )
		{
			int numPierce = (int)Stats[BulletStat.NumPiercing];
			int numBounce = (int)Stats[BulletStat.NumBouncing];

			if ( numPierce > 0 && !hitObstacle )
			{
				Pierce( other );
			}
			else
			{
				if ( numBounce > 0 )
				{
					Bounce( other );
				}
				else
				{
					if ( Stats[BulletStat.SplashDamagePercent] > 0f )
						Splash( except: other as Enemy );

					if ( Stats[BulletStat.Explosive] > 0f )
						Explode();

					bool overflow = Stats[BulletStat.OverflowPercent] > 0f && dmgUsed < dmg;
					if ( overflow )
					{
						Stats[BulletStat.Damage] = (dmg - dmgUsed) * Stats[BulletStat.OverflowPercent];
						_sizeDirty = true;

						HitThings.Add( other );
					}
					else
					{
						if ( BulletType == BulletType.Punch && (int)Stats[BulletStat.NumPunchExtraHits] > 0f )
						{
							Punch( other );
							return;
						}

						//Manager.Instance.SpawnBulletImpactParticles( WorldPosition, -Velocity, Color.White );
						Remove();
					}
				}
			}
		}
	}

	bool CheckCrit( ref float dmg )
	{
		bool playCritSfx = false;
		float sfxPitch = 0.85f;

		bool isCrit = false;
		var critChance = Stats[BulletStat.CriticalChance];
		while ( true )
		{
			if ( Game.Random.Float( 0f, 1f ) < critChance )
			{
				if ( Shooter.IsValid() && Shooter.Stats[PlayerStat.CritStreak] > 0f && Shooter.Stats[PlayerStat.CritStreakDmgAmount] > 0f )
					dmg *= (1f + Shooter.Stats[PlayerStat.CritStreak] * Shooter.Stats[PlayerStat.CritStreakDmgAmount]);

				dmg *= Math.Max( Stats[BulletStat.CriticalMultiplier], 0f );

				isCrit = true;
				playCritSfx = true;
				sfxPitch *= 1.2f;

				if ( Shooter.IsValid() && Shooter.Stats[PlayerStat.CritMultipleChance] > 0f )
				{
					critChance *= Shooter.Stats[PlayerStat.CritMultipleChance];
					continue;
				}
			}

			break;
		}

		if( playCritSfx )
			Manager.Instance.PlaySfxNearbyRpc( "crit2", Position2D, pitch: sfxPitch * Game.Random.Float(1.2f, 1.3f), volume: 0.75f, maxDist: 300f );

		return isCrit;
	}

	void Punch( Thing other )
	{
		Stats[BulletStat.NumPunchExtraHits] -= 1f;
		HitThings.Add( other );
	}

	void Pierce( Thing other )
	{
		Stats[BulletStat.NumPiercing] -= 1f;
		HitThings.Add( other );

		if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
			HomingDetector.Refresh();

		Shooter?.BulletPierce( this, other );

		if( (int)Stats[BulletStat.NumPiercing] <= 0 )
		{
			ShowPierce = false;

			if ( BulletType == BulletType.Normal )
				RefreshBodyGroups();
		}

		if ( Stats[BulletStat.AimAtCursorProgress] > 0f )
		{
			_shouldAimAtCursor = true;
			Stats[BulletStat.AimAtCursorProgress] = Game.Random.Float( 0.2f, 0.4f );
		}
	}

	void Bounce( Thing other )
	{
		Vector2 dir = (Position2D - other.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
					? (Position2D - other.Position2D).Normal
					: Utils.GetRandomVector();

		if ( Stats[BulletStat.BounceTarget] > 0f )
			dir = GetBounceTargetDir( dir, other );

		SetDirection( dir );

		ApplyBounceEffects();

		HitThings.Add( other );

		Shooter?.BulletBounce( this, other );
	}

	void ApplyBounceEffects( bool outOfBounds = false )
	{
		Stats[BulletStat.NumBouncing] -= 1f;
		ShouldCheckBounds = Stats[BulletStat.NumBouncing] > 0f;
		HitThings.Clear();

		if ( Stats[BulletStat.BounceDamageIncrease] > 0f )
		{
			Stats[BulletStat.Damage] *= (1f + Stats[BulletStat.BounceDamageIncrease]);
			_sizeDirty = true;
		}

		_timeSinceBounce = 0f;

		if ( Stats[BulletStat.BounceResetLifetime] > 0f )
			TimeSinceSpawn = 0f;

		if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
			HomingDetector.Refresh();

		_hasReturned = false;

		if ( (int)Stats[BulletStat.NumBouncing] <= 0 )
		{
			ShowBounce = false;

			if ( BulletType == BulletType.Normal )
				RefreshBodyGroups();
		}

		if( Stats[BulletStat.ArcHeight] > 0f && !outOfBounds )
		{
			TimeSinceSpawn = 0f;
			BaseZPos = WorldPosition.z;
		}

		if ( Stats[BulletStat.AimAtCursorProgress] > 0f )
		{
			_shouldAimAtCursor = true;
			Stats[BulletStat.AimAtCursorProgress] = Game.Random.Float( 0.2f, 0.4f );
		}
	}

	public void SetupArc( float arcHeight, float extraBounces )
	{
		Stats[BulletStat.ArcHeight] = arcHeight;
		Stats[BulletStat.NumBouncing] += extraBounces;
		if ( Stats[BulletStat.NumBouncing] > 0f )
		{
			ShowBounce = true;
			ShouldCheckBounds = true;
		}
		StartingNumBounce = (int)Stats[BulletStat.NumBouncing];
	}

	Vector2 GetBounceTargetDir( Vector2 dir, Thing other )
	{
		var closestUnit = Manager.Instance.GetClosestEnemy( Position2D, onlyCountsAsKill: false, except: other );
		if ( closestUnit.IsValid() )
		{
			dir = (closestUnit.Position2D - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
				? (closestUnit.Position2D - Position2D).Normal
				: Utils.GetRandomVector();
		}

		return dir;
	}

	// bullet doesn't ShouldCheckBounds unless it has bounces
	protected override void OnOutOfBounds( Direction direction )
	{
		base.OnOutOfBounds( direction );

		Vector2 dir = Velocity.Normal;

		if ( Stats[BulletStat.BounceTarget] > 0f )
			dir = GetBounceTargetDir( dir, other: null );

		SetDirection( dir );

		ApplyBounceEffects( outOfBounds: true );

		var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.4f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
		Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition, dir, Color.White, scaleMultiplier );

		Manager.Instance.PlaySfxNearbyRpc( "bullet.impact", Position2D, pitch: Game.Random.Float( 1.3f, 1.4f ), volume: 0.8f, maxDist: 250f );

		// todo: when enabled, PerkBulletBounceCopy seems to trigger way too often
		//Shooter?.BulletBounce( this, other: null );
	}

	public void Restart()
	{
		TimeSinceSpawn = 0f;
		_timeSinceBounce = 99f;
		HitThings.Clear();
		Stats[BulletStat.NumPiercing] = StartingNumPierce;
		Stats[BulletStat.NumBouncing] = StartingNumBounce;
		ShouldCheckBounds = Stats[BulletStat.NumBouncing] > 0f;

		if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
			HomingDetector.Refresh();

		_hasReturned = false;

		_shouldAimAtCursor = Stats[BulletStat.AimAtCursorProgress] > 0f;
	}

	public void SetDirection( Vector2 dir )
	{
		Velocity = dir * Velocity.Length;

		if( BulletType == BulletType.Armor )
		{
			WorldRotation = Rotation.From( -90f, -Utils.GetAngleDegreesFromVector( dir ), 0f );
			return;
		}

		WorldRotation = Rotation.From( 0f, -Utils.GetAngleDegreesFromVector( dir ), 0f );
	}

	public void ApplyHoming( Vector2 dir )
	{
		SetDirection( dir );
		HitThings.Clear();
		_timeSinceRandomMove = 0f;
	}

	public void Splash( Enemy except = null )
	{
		// todo: sfx *****

		float damage = Stats[BulletStat.Damage] * Stats[BulletStat.SplashDamagePercent];
		float radius = SPLASH_RADIUS * (Shooter?.Stats[PlayerStat.RadiusMultiplier] ?? 1f);

		//SplashDamageEffect( radius );
		Manager.Instance.SpawnRingRpc( Position2D, radius, new Color( 0.4f, 0.4f, 1f, 0.55f ), lifetime: Game.Random.Float(0.15f, 0.25f), path: "ring_spiky_2" );

		//Gizmo.Draw.Color = new Color( 1f, 0f, 1f, 1f );
		//Gizmo.Draw.LineSphere( WorldPosition, radius );

		//Manager.Instance.SpawnRing( Position2D, radius, Game.Random.Float( 0.2f, 0.3f ), new Color( 0.3f, 0.3f, 1f, 0.6f ) );

		Manager.Instance.DamageNearbyEnemies( Position2D, radius, damage, Stats[BulletStat.Force], DamageType.BulletSplash, Shooter, except );
	}

	[Rpc.Broadcast]
	public void RemoveRpc()
	{
		if ( IsProxy )
			return;

		GameObject.Destroy();
	}

	public void Remove()
	{
		_isRemoved = true;
		GameObject.Destroy();
	}

	[Rpc.Broadcast]
	public void LensBuff( float multiplier )
	{
		if ( IsLensBuffed )
			return;

		IsLensBuffed = true;

		var outline = GetComponent<HighlightOutline>( includeDisabled: true );
		if ( outline.IsValid() )
			outline.Enabled = true;

		//RenderColor = Color.Lerp( RenderColor, Color.FromBytes( 255, 30, 90, 255 ), 0.6f );

		//var glow = Components.GetOrCreate<Glow>();
		//glow.Width = 0.2f;
		//glow.Color = Color.FromBytes( 120, 0, 70, 255 );

		Manager.Instance.PlaySfxNearby( "lens", Position2D, pitch: Game.Random.Float( 1.1f, 1.25f ), volume: 0.4f, maxDist: 250f );

		if ( IsProxy )
			return;

		Stats[BulletStat.Damage] *= multiplier;
		_sizeDirty = true;
	}

	void HitGround()
	{
		// arc bullets might be able to bounce on ground
		if ( Stats[BulletStat.ArcHeight] > 0f && Stats[BulletStat.NumBouncing] > 0f && Shooter.IsValid() && Shooter.Stats[PlayerStat.ArcBulletsBounceGround] > 0f )
		{
			Stats[BulletStat.StartFromGround] = 1f;

			if ( Stats[BulletStat.BounceTarget] > 0f )
			{
				var dir = GetBounceTargetDir( Velocity.Normal, other: null );
				SetDirection( dir );
			}

			if ( BulletType != BulletType.Punch )
			{
				var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.5f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
				Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition.WithZ( 10f ), Vector3.Up, Color.White, scaleMultiplier );
			}

			ApplyBounceEffects();
			Shooter?.BulletBounce( this, other: null );

			//Manager.Instance.PlaySfxNearbyRpc( "bullet.impact", Position2D, pitch: Game.Random.Float( 1.6f, 1.7f ), volume: 0.5f, maxDist: 200f );

			return;
		}

		if ( BulletType != BulletType.Punch )
		{
			var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.5f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
			Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition.WithZ( 10f ), Vector3.Up, Color.White, scaleMultiplier );
		}

		if ( Shooter.IsValid() && BulletType != BulletType.Punch )
			Shooter.BulletHitGround( this );

		if ( Stats[BulletStat.SplashDamagePercent] > 0f )
			Splash();

		if ( Stats[BulletStat.Explosive] > 0f )
			Explode();

		Remove();
	}

	void Explode()
	{
		var damage = Stats[BulletStat.Damage];
		var radius = 75f
			* Utils.Map( damage, 1f, 5f, 0.4f, 1f )
			* Utils.Map( damage, 5f, 30f, 1f, 1.3f )
			* (Shooter.IsValid() ? Shooter.Stats[PlayerStat.RadiusMultiplier] * Shooter.Stats[PlayerStat.ExplosionSizeMultiplier] : 1f);
		
		Manager.Instance.CreateExplosionRpc( (Vector2)WorldPosition, radius, damage, repelRadius: radius * 1.15f, repelForce: damage * 20f, playerSource: Shooter, enemySource: null, enemyType: EnemyType.None, Color.Red );
	}
}